Merge pull request #24432 from edly-io/ziafazal/bd-18-bockchoy-tests-removal
[BD-18] Remove bockchoy tests
This commit is contained in:
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
Tools for creating edxnotes content fixture data.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
|
||||
import factory
|
||||
import requests
|
||||
|
||||
from common.test.acceptance.fixtures import EDXNOTES_STUB_URL
|
||||
|
||||
|
||||
class Range(factory.Factory):
|
||||
class Meta(object):
|
||||
model = dict
|
||||
|
||||
start = "/div[1]/p[1]"
|
||||
end = "/div[1]/p[1]"
|
||||
startOffset = 0
|
||||
endOffset = 8
|
||||
|
||||
|
||||
class Note(factory.Factory):
|
||||
class Meta(object):
|
||||
model = dict
|
||||
|
||||
user = "dummy-user"
|
||||
usage_id = "dummy-usage-id"
|
||||
course_id = "dummy-course-id"
|
||||
text = "dummy note text"
|
||||
quote = "dummy note quote"
|
||||
ranges = [Range()]
|
||||
|
||||
|
||||
class EdxNotesFixtureError(Exception):
|
||||
"""
|
||||
Error occurred while installing a edxnote fixture.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class EdxNotesFixture(object):
|
||||
notes = []
|
||||
|
||||
def create_notes(self, notes_list):
|
||||
self.notes = notes_list
|
||||
return self
|
||||
|
||||
def install(self):
|
||||
"""
|
||||
Push the data to the stub EdxNotes service.
|
||||
"""
|
||||
response = requests.post(
|
||||
'{}/create_notes'.format(EDXNOTES_STUB_URL),
|
||||
data=json.dumps(self.notes)
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise EdxNotesFixtureError(
|
||||
u"Could not create notes {0}. Status was {1}".format(
|
||||
json.dumps(self.notes), response.status_code
|
||||
)
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the stub EdxNotes service.
|
||||
"""
|
||||
self.notes = []
|
||||
response = requests.put('{}/cleanup'.format(EDXNOTES_STUB_URL))
|
||||
|
||||
if not response.ok:
|
||||
raise EdxNotesFixtureError(
|
||||
u"Could not cleanup EdxNotes service {0}. Status was {1}".format(
|
||||
json.dumps(self.notes), response.status_code
|
||||
)
|
||||
)
|
||||
|
||||
return self
|
||||
@@ -1,51 +0,0 @@
|
||||
"""
|
||||
Fixture to configure XQueue response.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
from common.test.acceptance.fixtures import XQUEUE_STUB_URL
|
||||
|
||||
|
||||
class XQueueResponseFixtureError(Exception):
|
||||
"""
|
||||
Error occurred while configuring the stub XQueue.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class XQueueResponseFixture(object):
|
||||
"""
|
||||
Configure the XQueue stub's response to submissions.
|
||||
"""
|
||||
|
||||
def __init__(self, pattern, response_dict):
|
||||
"""
|
||||
Configure XQueue stub to POST `response_dict` (a dictionary)
|
||||
back to the LMS when it receives a submission that contains the string
|
||||
`pattern`.
|
||||
|
||||
Remember that there is one XQueue stub shared by all the tests;
|
||||
if possible, you should have tests use unique queue names
|
||||
to avoid conflict between tests running in parallel.
|
||||
"""
|
||||
self._pattern = pattern
|
||||
self._response_dict = response_dict
|
||||
|
||||
def install(self):
|
||||
"""
|
||||
Configure the stub via HTTP.
|
||||
"""
|
||||
url = XQUEUE_STUB_URL + "/set_config"
|
||||
|
||||
# Configure the stub to respond to submissions to our queue
|
||||
payload = {self._pattern: json.dumps(self._response_dict)}
|
||||
response = requests.put(url, data=payload)
|
||||
|
||||
if not response.ok:
|
||||
raise XQueueResponseFixtureError(
|
||||
u"Could not configure XQueue stub for queue '{1}'. Status code: {2}".format(
|
||||
self._pattern, self._response_dict))
|
||||
@@ -1,71 +0,0 @@
|
||||
"""
|
||||
Common mixin for paginated UIs.
|
||||
"""
|
||||
|
||||
|
||||
import six
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
|
||||
class PaginatedUIMixin(object):
|
||||
"""Common methods used for paginated UI."""
|
||||
|
||||
PAGINATION_FOOTER_CSS = 'nav.bottom'
|
||||
PAGE_NUMBER_INPUT_CSS = 'input#page-number-input'
|
||||
NEXT_PAGE_BUTTON_CSS = 'button.next-page-link'
|
||||
PREVIOUS_PAGE_BUTTON_CSS = 'button.previous-page-link'
|
||||
PAGINATION_HEADER_TEXT_CSS = 'div.search-tools'
|
||||
CURRENT_PAGE_NUMBER_CSS = 'span.current-page'
|
||||
TOTAL_PAGES_CSS = 'span.total-pages'
|
||||
|
||||
def get_pagination_header_text(self):
|
||||
"""Return the text showing which items the user is currently viewing."""
|
||||
return self.q(css=self.PAGINATION_HEADER_TEXT_CSS).text[0]
|
||||
|
||||
def pagination_controls_visible(self):
|
||||
"""Return true if the pagination controls in the footer are visible."""
|
||||
footer_nav = self.q(css=self.PAGINATION_FOOTER_CSS).results[0]
|
||||
# The footer element itself is non-generic, so check above it
|
||||
footer_el = footer_nav.find_element_by_xpath('..')
|
||||
return 'hidden' not in footer_el.get_attribute('class').split()
|
||||
|
||||
def get_current_page_number(self):
|
||||
"""Return the the current page number."""
|
||||
return int(self.q(css=self.CURRENT_PAGE_NUMBER_CSS).text[0])
|
||||
|
||||
@property
|
||||
def get_total_pages(self):
|
||||
"""Returns the total page value"""
|
||||
return int(self.q(css=self.TOTAL_PAGES_CSS).text[0])
|
||||
|
||||
def go_to_page(self, page_number):
|
||||
"""Go to the given page_number in the paginated list results."""
|
||||
self.q(css=self.PAGE_NUMBER_INPUT_CSS).results[0].send_keys(six.text_type(page_number), Keys.ENTER)
|
||||
self.wait_for_ajax()
|
||||
|
||||
def press_next_page_button(self):
|
||||
"""Press the next page button in the paginated list results."""
|
||||
self.q(css=self.NEXT_PAGE_BUTTON_CSS).click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def press_previous_page_button(self):
|
||||
"""Press the previous page button in the paginated list results."""
|
||||
self.q(css=self.PREVIOUS_PAGE_BUTTON_CSS).click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def is_next_page_button_enabled(self):
|
||||
"""Return whether the 'next page' button can be clicked."""
|
||||
return self.is_enabled(self.NEXT_PAGE_BUTTON_CSS)
|
||||
|
||||
def is_previous_page_button_enabled(self):
|
||||
"""Return whether the 'previous page' button can be clicked."""
|
||||
return self.is_enabled(self.PREVIOUS_PAGE_BUTTON_CSS)
|
||||
|
||||
def is_enabled(self, css):
|
||||
"""Return whether the given element is not disabled."""
|
||||
return 'is-disabled' not in self.q(css=css).attrs('class')[0]
|
||||
|
||||
@property
|
||||
def footer_visible(self):
|
||||
""" Return True if footer is visible else False"""
|
||||
return self.q(css='.pagination.bottom').visible
|
||||
@@ -4,63 +4,11 @@ Utility methods common to Studio and the LMS.
|
||||
|
||||
|
||||
import six
|
||||
from bok_choy.promise import BrokenPromise
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
|
||||
from common.test.acceptance.pages.lms.create_mode import ModeCreationPage
|
||||
from common.test.acceptance.pages.lms.track_selection import TrackSelectionPage
|
||||
from common.test.acceptance.tests.helpers import disable_animations
|
||||
|
||||
|
||||
def sync_on_notification(page, style='default', wait_for_hide=False):
|
||||
"""
|
||||
Sync on notifications but do not raise errors.
|
||||
|
||||
A BrokenPromise in the wait_for probably means that we missed it.
|
||||
We should just swallow this error and not raise it for reasons including:
|
||||
* We are not specifically testing this functionality
|
||||
* This functionality is covered by unit tests
|
||||
* This verification method is prone to flakiness
|
||||
and browser version dependencies
|
||||
|
||||
See classes in edx-platform:
|
||||
lms/static/sass/elements/_system-feedback.scss
|
||||
"""
|
||||
hiding_class = 'is-hiding'
|
||||
shown_class = 'is-shown'
|
||||
|
||||
def notification_has_class(style, el_class):
|
||||
"""
|
||||
Return a boolean representing whether
|
||||
the notification has the class applied.
|
||||
"""
|
||||
if style == 'mini':
|
||||
css_string = '.wrapper-notification-mini.{}'
|
||||
else:
|
||||
css_string = '.wrapper-notification-confirmation.{}'
|
||||
return page.q(css=css_string.format(el_class)).present
|
||||
|
||||
# Wait for the notification to show.
|
||||
# This notification appears very quickly and maybe missed. Don't raise an error.
|
||||
try:
|
||||
page.wait_for(
|
||||
lambda: notification_has_class(style, shown_class),
|
||||
'Notification should have been shown.',
|
||||
timeout=5
|
||||
)
|
||||
except BrokenPromise as _err:
|
||||
pass
|
||||
|
||||
# Now wait for it to hide.
|
||||
# This is not required for web page interaction, so not really needed.
|
||||
if wait_for_hide:
|
||||
page.wait_for(
|
||||
lambda: notification_has_class(style, hiding_class),
|
||||
'Notification should have hidden.'
|
||||
)
|
||||
|
||||
|
||||
def click_css(page, css, source_index=0, require_notification=True):
|
||||
def click_css(page, css, source_index=0):
|
||||
"""
|
||||
Click the button/link with the given css and index on the specified page (subclass of PageObject).
|
||||
|
||||
@@ -80,9 +28,6 @@ def click_css(page, css, source_index=0, require_notification=True):
|
||||
# Click on the element in the browser
|
||||
page.q(css=css).filter(_is_visible).nth(source_index).click()
|
||||
|
||||
if require_notification:
|
||||
sync_on_notification(page)
|
||||
|
||||
# Some buttons trigger ajax posts
|
||||
# (e.g. .add-missing-groups-button as configured in split_test_author_view.js)
|
||||
# so after you click anything wait for the ajax call to finish
|
||||
@@ -99,46 +44,3 @@ def confirm_prompt(page, cancel=False, require_notification=None):
|
||||
page.wait_for_element_visibility(confirmation_button_css, 'Confirmation button is visible')
|
||||
require_notification = (not cancel) if require_notification is None else require_notification
|
||||
click_css(page, confirmation_button_css, require_notification=require_notification)
|
||||
|
||||
|
||||
def hover(browser, element):
|
||||
"""
|
||||
Hover over an element.
|
||||
"""
|
||||
ActionChains(browser).move_to_element(element).perform()
|
||||
|
||||
|
||||
def enroll_user_track(browser, course_id, track):
|
||||
"""
|
||||
Utility method to enroll a user in the audit or verified user track. Creates and connects to the
|
||||
necessary pages. Selects the track and handles payment for verified.
|
||||
Supported tracks are 'verified' or 'audit'.
|
||||
"""
|
||||
track_selection = TrackSelectionPage(browser, course_id)
|
||||
|
||||
# Select track
|
||||
track_selection.visit()
|
||||
track_selection.enroll(track)
|
||||
|
||||
|
||||
def add_enrollment_course_modes(browser, course_id, tracks):
|
||||
"""
|
||||
Add the specified array of tracks to the given course.
|
||||
Supported tracks are `verified` and `audit` (all others will be ignored),
|
||||
and display names assigned are `Verified` and `Audit`, respectively.
|
||||
"""
|
||||
for track in tracks:
|
||||
if track == 'audit':
|
||||
# Add an audit mode to the course
|
||||
ModeCreationPage(
|
||||
browser,
|
||||
course_id, mode_slug='audit',
|
||||
mode_display_name='Audit'
|
||||
).visit()
|
||||
|
||||
elif track == 'verified':
|
||||
# Add a verified mode to the course
|
||||
ModeCreationPage(
|
||||
browser, course_id, mode_slug='verified',
|
||||
mode_display_name='Verified', min_price=10
|
||||
).visit()
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
Pages object for the Django's /admin/ views.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
|
||||
|
||||
class ChangeUserAdminPage(PageObject):
|
||||
"""
|
||||
Change user page in Django's admin.
|
||||
"""
|
||||
def __init__(self, browser, user_pk):
|
||||
super(ChangeUserAdminPage, self).__init__(browser)
|
||||
self.user_pk = user_pk
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
Returns the page URL for the page based on self.user_pk.
|
||||
"""
|
||||
|
||||
return u'{base}/admin/auth/user/{user_pk}/'.format(
|
||||
base=BASE_URL,
|
||||
user_pk=self.user_pk,
|
||||
)
|
||||
|
||||
@property
|
||||
def username(self):
|
||||
"""
|
||||
Reads the read-only username.
|
||||
"""
|
||||
return self.q(css='.field-username .readonly').text[0]
|
||||
|
||||
@property
|
||||
def first_name_element(self):
|
||||
"""
|
||||
Selects the first name element.
|
||||
"""
|
||||
return self.q(css='[name="first_name"]')
|
||||
|
||||
@property
|
||||
def first_name(self):
|
||||
"""
|
||||
Reads the first name value from the input field.
|
||||
"""
|
||||
return self.first_name_element.attrs('value')[0]
|
||||
|
||||
@property
|
||||
def submit_element(self):
|
||||
"""
|
||||
Gets the "Save" submit element.
|
||||
|
||||
Note that there are multiple submit elements in the change view.
|
||||
"""
|
||||
return self.q(css='input.default[type="submit"]')
|
||||
|
||||
def submit(self):
|
||||
"""
|
||||
Submits the form.
|
||||
"""
|
||||
self.submit_element.click()
|
||||
|
||||
def change_first_name(self, first_name):
|
||||
"""
|
||||
Changes the first name and submits the form.
|
||||
|
||||
Args:
|
||||
first_name: The first name as unicode.
|
||||
|
||||
"""
|
||||
|
||||
self.first_name_element.fill(first_name)
|
||||
self.submit()
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Returns True if the browser is currently on the right page.
|
||||
"""
|
||||
return self.q(css='#user_form').present
|
||||
@@ -1,60 +0,0 @@
|
||||
"""
|
||||
Courseware Boomarks
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.pages.common.paging import PaginatedUIMixin
|
||||
from common.test.acceptance.pages.lms.course_page import CoursePage
|
||||
|
||||
|
||||
class BookmarksPage(CoursePage, PaginatedUIMixin):
|
||||
"""
|
||||
Courseware Bookmarks Page.
|
||||
"""
|
||||
url_path = "bookmarks"
|
||||
BOOKMARKS_BUTTON_SELECTOR = '.bookmarks-list-button'
|
||||
BOOKMARKS_ELEMENT_SELECTOR = '#my-bookmarks'
|
||||
BOOKMARKED_ITEMS_SELECTOR = '.bookmarks-results-list .bookmarks-results-list-item'
|
||||
BOOKMARKED_BREADCRUMBS = BOOKMARKED_ITEMS_SELECTOR + ' .list-item-breadcrumbtrail'
|
||||
|
||||
def is_browser_on_page(self):
|
||||
""" Verify if we are on correct page """
|
||||
return self.q(css=self.BOOKMARKS_ELEMENT_SELECTOR).present
|
||||
|
||||
def bookmarks_button_visible(self):
|
||||
""" Check if bookmarks button is visible """
|
||||
return self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).visible
|
||||
|
||||
def results_present(self):
|
||||
""" Check if bookmarks results are present """
|
||||
return self.q(css=self.BOOKMARKS_ELEMENT_SELECTOR).present
|
||||
|
||||
def results_header_text(self):
|
||||
""" Returns the bookmarks results header text """
|
||||
return self.q(css='.bookmarks-results-header').text[0]
|
||||
|
||||
def empty_header_text(self):
|
||||
""" Returns the bookmarks empty header text """
|
||||
return self.q(css='.bookmarks-empty-header').text[0]
|
||||
|
||||
def empty_list_text(self):
|
||||
""" Returns the bookmarks empty list text """
|
||||
return self.q(css='.bookmarks-empty-detail-title').text[0]
|
||||
|
||||
def count(self):
|
||||
""" Returns the total number of bookmarks in the list """
|
||||
return len(self.q(css=self.BOOKMARKED_ITEMS_SELECTOR).results)
|
||||
|
||||
def breadcrumbs(self):
|
||||
""" Return list of breadcrumbs for all bookmarks """
|
||||
breadcrumbs = self.q(css=self.BOOKMARKED_BREADCRUMBS).text
|
||||
return [breadcrumb.replace('\n', '').split('-') for breadcrumb in breadcrumbs]
|
||||
|
||||
def click_bookmarked_block(self, index):
|
||||
"""
|
||||
Click on bookmarked block at index `index`
|
||||
|
||||
Arguments:
|
||||
index (int): bookmark index in the list
|
||||
"""
|
||||
self.q(css=self.BOOKMARKED_ITEMS_SELECTOR).nth(index).click()
|
||||
@@ -1,61 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Module for Certificates pages.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
|
||||
|
||||
class CertificatePage(PageObject):
|
||||
"""
|
||||
Certificate web view page.
|
||||
"""
|
||||
|
||||
url_path = "certificates"
|
||||
|
||||
def __init__(self, browser, user_id, course_id):
|
||||
"""Initialize the page.
|
||||
|
||||
Arguments:
|
||||
browser (Browser): The browser instance.
|
||||
user_id: id of the user whom certificate is awarded
|
||||
course_id: course key of the course where certificate is awarded
|
||||
"""
|
||||
super(CertificatePage, self).__init__(browser)
|
||||
self.user_id = user_id
|
||||
self.course_id = course_id
|
||||
|
||||
def is_browser_on_page(self):
|
||||
""" Checks if certificate web view page is being viewed """
|
||||
return self.q(css='section.about-accomplishments').present
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
Construct a URL to the page
|
||||
"""
|
||||
return BASE_URL + "/" + self.url_path + "/user/" + self.user_id + "/course/" + self.course_id
|
||||
|
||||
@property
|
||||
def accomplishment_banner(self):
|
||||
"""
|
||||
returns accomplishment banner.
|
||||
"""
|
||||
return self.q(css='section.banner-user')
|
||||
|
||||
@property
|
||||
def add_to_linkedin_profile_button(self):
|
||||
"""
|
||||
returns add to LinkedIn profile button
|
||||
"""
|
||||
return self.q(css='button.action-linkedin-profile')
|
||||
|
||||
@property
|
||||
def add_to_facebook_profile_button(self):
|
||||
"""
|
||||
returns Facebook share button
|
||||
"""
|
||||
return self.q(css='button.action-share-facebook')
|
||||
@@ -1,26 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Mixins for completion.
|
||||
"""
|
||||
|
||||
|
||||
class CompletionOnViewMixin(object):
|
||||
"""
|
||||
Methods for testing completion on view.
|
||||
"""
|
||||
|
||||
def xblock_components_mark_completed_on_view_value(self):
|
||||
"""
|
||||
Return the xblock components data-mark-completed-on-view-after-delay value.
|
||||
"""
|
||||
return self.q(css=self.xblock_component_selector).attrs('data-mark-completed-on-view-after-delay')
|
||||
|
||||
def wait_for_xblock_component_to_be_marked_completed_on_view(self, index=0):
|
||||
"""
|
||||
Wait for xblock component to be marked completed on view.
|
||||
|
||||
Arguments
|
||||
index (int): index of block to wait on. (default is 0)
|
||||
"""
|
||||
self.wait_for(lambda: (self.xblock_components_mark_completed_on_view_value()[index] == '0'),
|
||||
'Waiting for xblock to be marked completed on view.')
|
||||
@@ -1,37 +0,0 @@
|
||||
"""
|
||||
Course about page (with registration button)
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.pages.lms.course_page import CoursePage
|
||||
from common.test.acceptance.pages.lms.login_and_register import RegisterPage
|
||||
|
||||
|
||||
class CourseAboutPage(CoursePage):
|
||||
"""
|
||||
Course about page (with registration button)
|
||||
"""
|
||||
|
||||
url_path = "about"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='section.course-info').present
|
||||
|
||||
def register(self):
|
||||
"""
|
||||
Navigate to the registration page.
|
||||
Waits for the registration page to load, then
|
||||
returns the registration page object.
|
||||
"""
|
||||
self.q(css='a.register').first.click()
|
||||
|
||||
registration_page = RegisterPage(self.browser, self.course_id)
|
||||
registration_page.wait_for_page()
|
||||
return registration_page
|
||||
|
||||
def enroll_in_course(self):
|
||||
"""
|
||||
Click on enroll button
|
||||
"""
|
||||
self.wait_for_element_visibility('.register', 'Enroll button is present')
|
||||
self.q(css='.register').first.click()
|
||||
@@ -3,15 +3,7 @@ LMS Course Home page object
|
||||
"""
|
||||
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import BrokenPromise
|
||||
from six import text_type
|
||||
|
||||
from .bookmarks import BookmarksPage
|
||||
from .course_page import CoursePage
|
||||
from .courseware import CoursewarePage
|
||||
from .staff_view import StaffPreviewPage
|
||||
|
||||
|
||||
@@ -30,316 +22,6 @@ class CourseHomePage(CoursePage):
|
||||
def __init__(self, browser, course_id):
|
||||
super(CourseHomePage, self).__init__(browser, course_id)
|
||||
self.course_id = course_id
|
||||
self.outline = CourseOutlinePage(browser, self)
|
||||
self.preview = StaffPreviewPage(browser, self)
|
||||
# TODO: TNL-6546: Remove the following
|
||||
self.course_outline_page = False
|
||||
|
||||
def select_course_goal(self):
|
||||
""" Click on a course goal in a message """
|
||||
self.q(css='button.goal-option').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def is_course_goal_success_message_shown(self):
|
||||
""" Verifies course goal success message appears. """
|
||||
return self.q(css='.success-message').present
|
||||
|
||||
def is_course_goal_update_field_shown(self):
|
||||
""" Verifies course goal success message appears. """
|
||||
return self.q(css='.current-goal-container').visible
|
||||
|
||||
def is_course_goal_update_icon_shown(self, valid=True):
|
||||
""" Verifies course goal success or error icon appears. """
|
||||
correct_icon = 'check' if valid else 'close'
|
||||
return self.q(css='.fa-{icon}'.format(icon=correct_icon)).present
|
||||
|
||||
def click_bookmarks_button(self):
|
||||
""" Click on Bookmarks button """
|
||||
self.q(css='.bookmarks-list-button').first.click()
|
||||
bookmarks_page = BookmarksPage(self.browser, self.course_id)
|
||||
bookmarks_page.visit()
|
||||
|
||||
def resume_course_from_header(self):
|
||||
"""
|
||||
Navigate to courseware using Resume Course button in the header.
|
||||
"""
|
||||
self.q(css=self.HEADER_RESUME_COURSE_SELECTOR).first.click()
|
||||
courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
courseware_page.wait_for_page()
|
||||
|
||||
def search_for_term(self, search_term):
|
||||
"""
|
||||
Search within a class for a particular term.
|
||||
"""
|
||||
self.q(css='.search-form > .search-input').fill(search_term)
|
||||
self.q(css='.search-form .search-button').click()
|
||||
return CourseSearchResultsPage(self.browser, self.course_id)
|
||||
|
||||
|
||||
class CourseOutlinePage(PageObject):
|
||||
"""
|
||||
Course outline fragment of page.
|
||||
"""
|
||||
|
||||
url = None
|
||||
|
||||
def __init__(self, browser, parent_page):
|
||||
super(CourseOutlinePage, self).__init__(browser)
|
||||
self.parent_page = parent_page
|
||||
self._section_selector = '.outline-item.section'
|
||||
self._subsection_selector = '.subsection.accordion'
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.parent_page.is_browser_on_page
|
||||
|
||||
@property
|
||||
def sections(self):
|
||||
"""
|
||||
Return a dictionary representation of sections and subsections.
|
||||
|
||||
Example:
|
||||
|
||||
{
|
||||
'Introduction': ['Course Overview'],
|
||||
'Week 1': ['Lesson 1', 'Lesson 2', 'Homework']
|
||||
'Final Exam': ['Final Exam']
|
||||
}
|
||||
|
||||
You can use these titles in `go_to_section` to navigate to the section.
|
||||
"""
|
||||
return self._get_outline_structure_as_dictionary()
|
||||
|
||||
@property
|
||||
def num_sections(self):
|
||||
"""
|
||||
Return the number of sections
|
||||
"""
|
||||
return len(self._get_sections_as_selenium_webelements())
|
||||
|
||||
@property
|
||||
def num_subsections(self, section_title=None):
|
||||
"""
|
||||
Return the number of subsections.
|
||||
|
||||
Arguments:
|
||||
section_title: The section for which to return the number of
|
||||
subsections. If None, default to the first section.
|
||||
"""
|
||||
if section_title:
|
||||
section_index = self._section_title_to_index(section_title)
|
||||
if not section_index:
|
||||
return
|
||||
else:
|
||||
section_index = 0
|
||||
|
||||
sections = self._get_sections_as_selenium_webelements()
|
||||
subsections = self._get_subsections(sections[section_index])
|
||||
return len(subsections)
|
||||
|
||||
@property
|
||||
def num_units(self):
|
||||
"""
|
||||
Return the number of units in the first subsection.
|
||||
|
||||
This method returns the number of units in the horizontal navigation
|
||||
bar; not the course outline.
|
||||
"""
|
||||
return len(self.q(css='.sequence-list-wrapper ol li'))
|
||||
|
||||
def go_to_section(self, section_title, subsection_title):
|
||||
"""
|
||||
Go to the section/subsection in the courseware.
|
||||
Every section must have at least one subsection, so specify
|
||||
both the section and subsection title.
|
||||
|
||||
Example:
|
||||
go_to_section("Week 1", "Lesson 1")
|
||||
"""
|
||||
subsection_webelements = self._get_subsections_as_selenium_webelements()
|
||||
subsection_titles = [self._get_outline_element_title(sub_webel)
|
||||
for sub_webel in subsection_webelements]
|
||||
|
||||
try:
|
||||
subsection_index = subsection_titles.index(text_type(subsection_title))
|
||||
except ValueError:
|
||||
raise ValueError(u"Could not find subsection '{0}' in section '{1}'".format(
|
||||
subsection_title, section_title
|
||||
))
|
||||
|
||||
target_subsection = subsection_webelements[subsection_index]
|
||||
units = self._get_units(target_subsection)
|
||||
|
||||
# Click the subsection's first problem and ensure that the page finishes
|
||||
# reloading
|
||||
units[0].location_once_scrolled_into_view # pylint: disable=W0104
|
||||
units[0].click()
|
||||
|
||||
self._wait_for_course_section(section_title, subsection_title)
|
||||
|
||||
def go_to_section_by_index(self, section_index, subsection_index):
|
||||
"""
|
||||
Go to the section/subsection in the courseware.
|
||||
Every section must have at least one subsection, so specify both the
|
||||
section and subsection indices.
|
||||
|
||||
Arguments:
|
||||
section_index: A 0-based index of the section to navigate to.
|
||||
subsection_index: A 0-based index of the subsection to navigate to.
|
||||
|
||||
"""
|
||||
try:
|
||||
section_title = self._section_titles()[section_index]
|
||||
except IndexError:
|
||||
raise ValueError(u"Section index '{0}' is out of range.".format(section_index))
|
||||
try:
|
||||
subsection_title = self._subsection_titles(section_index)[subsection_index]
|
||||
except IndexError:
|
||||
raise ValueError(u"Subsection index '{0}' in section index '{1}' is out of range.".format(
|
||||
subsection_index, section_index
|
||||
))
|
||||
|
||||
self.go_to_section(section_title, subsection_title)
|
||||
|
||||
def _section_title_to_index(self, section_title):
|
||||
"""
|
||||
Get the section title index given the section title.
|
||||
"""
|
||||
try:
|
||||
section_index = self._section_titles().index(section_title)
|
||||
except ValueError:
|
||||
raise ValueError(u"Could not find section '{0}'".format(section_title))
|
||||
|
||||
return section_index
|
||||
|
||||
def resume_course_from_outline(self):
|
||||
"""
|
||||
Navigate to courseware using Resume Course button in the header.
|
||||
"""
|
||||
self.q(css='.btn.btn-primary.action-resume-course').results[0].click()
|
||||
courseware_page = CoursewarePage(self.browser, self.parent_page.course_id)
|
||||
courseware_page.wait_for_page()
|
||||
|
||||
def _section_titles(self):
|
||||
"""
|
||||
Return a list of all section titles on the page.
|
||||
"""
|
||||
outline_sections = self._get_sections_as_selenium_webelements()
|
||||
section_titles = [self._get_outline_element_title(section) for section in outline_sections]
|
||||
return section_titles
|
||||
|
||||
def _subsection_titles(self, section_index):
|
||||
"""
|
||||
Return a list of all subsection titles on the page
|
||||
for the section at index `section_index` (starts at 0).
|
||||
"""
|
||||
outline_sections = self._get_sections_as_selenium_webelements()
|
||||
target_section = outline_sections[section_index]
|
||||
target_subsections = self._get_subsections(target_section)
|
||||
subsection_titles = [self._get_outline_element_title(subsection)
|
||||
for subsection in target_subsections]
|
||||
return subsection_titles
|
||||
|
||||
def _wait_for_course_section(self, section_title, subsection_title):
|
||||
"""
|
||||
Ensures the user navigates to the course content page with the correct section and
|
||||
subsection.
|
||||
"""
|
||||
courseware_page = CoursewarePage(self.browser, self.parent_page.course_id)
|
||||
courseware_page.wait_for_page()
|
||||
|
||||
# TODO: TNL-6546: Remove this if/visit_course_outline_page
|
||||
if self.parent_page.course_outline_page:
|
||||
courseware_page.nav.visit_course_outline_page()
|
||||
|
||||
self.wait_for(
|
||||
promise_check_func=lambda: courseware_page.nav.is_on_section(
|
||||
section_title, subsection_title),
|
||||
description=u"Waiting for course page with section '{0}' and subsection '{1}'".format(
|
||||
section_title, subsection_title)
|
||||
)
|
||||
|
||||
def _get_outline_structure_as_dictionary(self):
|
||||
'''
|
||||
Implements self.sections().
|
||||
'''
|
||||
outline_dict = OrderedDict()
|
||||
|
||||
try:
|
||||
outline_sections = self._get_sections_as_selenium_webelements()
|
||||
except BrokenPromise:
|
||||
outline_sections = []
|
||||
|
||||
for section in outline_sections:
|
||||
subsections = self._get_subsections(section)
|
||||
section_title = self._get_outline_element_title(section)
|
||||
subsection_titles = [self._get_outline_element_title(subsection)
|
||||
for subsection in subsections]
|
||||
outline_dict[section_title] = subsection_titles
|
||||
|
||||
return outline_dict
|
||||
|
||||
@staticmethod
|
||||
def _is_html_element_aria_expanded(html_element):
|
||||
return html_element.get_attribute('aria-expanded') == u'true'
|
||||
|
||||
@staticmethod
|
||||
def _get_outline_element_title(outline_element):
|
||||
return outline_element.text.split('\n')[0]
|
||||
|
||||
def _get_subsections(self, section):
|
||||
self._expand_all_outline_folds()
|
||||
return section.find_elements_by_css_selector(self._subsection_selector)
|
||||
|
||||
def _get_units(self, subsection):
|
||||
self._expand_all_outline_folds()
|
||||
return subsection.find_elements_by_tag_name('a')
|
||||
|
||||
def _get_sections_as_selenium_webelements(self):
|
||||
self._expand_all_outline_folds()
|
||||
return self.q(css=self._section_selector).results
|
||||
|
||||
def _get_subsections_as_selenium_webelements(self):
|
||||
self._expand_all_outline_folds()
|
||||
return self.q(css=self._subsection_selector).results
|
||||
|
||||
def get_subsection_due_date(self, index=0):
|
||||
"""
|
||||
Get the due date for the given index sub-section on the LMS outline.
|
||||
"""
|
||||
results = self.q(css='div.details > span.subtitle > span.subtitle-name').results
|
||||
return results[index].text if results else None
|
||||
|
||||
def _expand_all_outline_folds(self):
|
||||
'''
|
||||
Expands all parts of the collapsible outline.
|
||||
'''
|
||||
expand_button_search_results = self.q(
|
||||
css='#expand-collapse-outline-all-button'
|
||||
).results
|
||||
|
||||
if not expand_button_search_results:
|
||||
return
|
||||
|
||||
expand_button = expand_button_search_results[0]
|
||||
|
||||
if not self._is_html_element_aria_expanded(expand_button):
|
||||
expand_button.click()
|
||||
|
||||
|
||||
class CourseSearchResultsPage(CoursePage):
|
||||
"""
|
||||
Course search page
|
||||
"""
|
||||
|
||||
# url = "courses/{course_id}/search/?query={query_string}"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='.page-content > .search-results').present
|
||||
|
||||
def __init__(self, browser, course_id):
|
||||
super(CourseSearchResultsPage, self).__init__(browser, course_id)
|
||||
self.course_id = course_id
|
||||
|
||||
@property
|
||||
def search_results(self):
|
||||
return self.q(css='.search-results-item')
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"""
|
||||
Course info page.
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.pages.lms.course_page import CoursePage
|
||||
|
||||
|
||||
class CourseInfoPage(CoursePage):
|
||||
"""
|
||||
Course info.
|
||||
"""
|
||||
|
||||
url_path = "info"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='section.updates').present
|
||||
|
||||
@property
|
||||
def num_updates(self):
|
||||
"""
|
||||
Return the number of updates on the page.
|
||||
"""
|
||||
return len(self.q(css='.updates .updates-article').results)
|
||||
|
||||
@property
|
||||
def handout_links(self):
|
||||
"""
|
||||
Return a list of handout assets links.
|
||||
"""
|
||||
return self.q(css='section.handouts ol li a').map(lambda el: el.get_attribute('href')).results
|
||||
@@ -3,20 +3,12 @@ Courseware page.
|
||||
"""
|
||||
|
||||
|
||||
import re
|
||||
|
||||
from bok_choy.page_object import PageObject, unguarded
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from six.moves import range
|
||||
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
from common.test.acceptance.pages.lms.bookmarks import BookmarksPage
|
||||
from common.test.acceptance.pages.lms.completion import CompletionOnViewMixin
|
||||
from common.test.acceptance.pages.lms.course_page import CoursePage
|
||||
|
||||
|
||||
class CoursewarePage(CoursePage, CompletionOnViewMixin):
|
||||
class CoursewarePage(CoursePage):
|
||||
"""
|
||||
Course info.
|
||||
"""
|
||||
@@ -30,102 +22,11 @@ class CoursewarePage(CoursePage, CompletionOnViewMixin):
|
||||
|
||||
def __init__(self, browser, course_id):
|
||||
super(CoursewarePage, self).__init__(browser, course_id)
|
||||
self.nav = CourseNavPage(browser, self)
|
||||
# self.nav = CourseNavPage(browser, self)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='.course-content').present
|
||||
|
||||
# TODO: TNL-6546: Remove and find callers
|
||||
@property
|
||||
def chapter_count_in_navigation(self):
|
||||
"""
|
||||
Returns count of chapters available on LHS navigation.
|
||||
"""
|
||||
return len(self.q(css='nav.course-navigation a.chapter'))
|
||||
|
||||
# TODO: TNL-6546: Remove and find callers.
|
||||
@property
|
||||
def num_sections(self):
|
||||
"""
|
||||
Return the number of sections in the sidebar on the page
|
||||
"""
|
||||
return len(self.q(css=self.section_selector))
|
||||
|
||||
# TODO: TNL-6546: Remove and find callers.
|
||||
@property
|
||||
def num_subsections(self):
|
||||
"""
|
||||
Return the number of subsections in the sidebar on the page, including in collapsed sections
|
||||
"""
|
||||
return len(self.q(css=self.subsection_selector))
|
||||
|
||||
@property
|
||||
def xblock_components(self):
|
||||
"""
|
||||
Return the xblock components within the unit on the page.
|
||||
"""
|
||||
return self.q(css=self.xblock_component_selector)
|
||||
|
||||
@property
|
||||
def num_xblock_components(self):
|
||||
"""
|
||||
Return the number of rendered xblocks within the unit on the page
|
||||
"""
|
||||
return len(self.xblock_components)
|
||||
|
||||
def xblock_component_type(self, index=0):
|
||||
"""
|
||||
Extract rendered xblock component type.
|
||||
|
||||
Returns:
|
||||
str: xblock module type
|
||||
index: which xblock to query, where the index is the vertical display within the page
|
||||
(default is 0)
|
||||
"""
|
||||
return self.q(css=self.xblock_component_selector).attrs('data-block-type')[index]
|
||||
|
||||
def xblock_component_html_content(self, index=0):
|
||||
"""
|
||||
Extract rendered xblock component html content.
|
||||
|
||||
Returns:
|
||||
str: xblock module html content
|
||||
index: which xblock to query, where the index is the vertical display within the page
|
||||
(default is 0)
|
||||
|
||||
"""
|
||||
# When Student Notes feature is enabled, it looks for the content inside
|
||||
# `.edx-notes-wrapper-content` element (Otherwise, you will get an
|
||||
# additional html related to Student Notes).
|
||||
element = self.q(css=u'{} .edx-notes-wrapper-content'.format(self.xblock_component_selector))
|
||||
if element.first:
|
||||
return element.attrs('innerHTML')[index].strip()
|
||||
else:
|
||||
return self.q(css=self.xblock_component_selector).attrs('innerHTML')[index].strip()
|
||||
|
||||
def verify_tooltips_displayed(self):
|
||||
"""
|
||||
Verify that all sequence navigation bar tooltips are being displayed upon mouse hover.
|
||||
|
||||
If a tooltip does not appear, raise a BrokenPromise.
|
||||
"""
|
||||
for index, tab in enumerate(self.q(css='#sequence-list > li')):
|
||||
ActionChains(self.browser).move_to_element(tab).perform()
|
||||
self.wait_for_element_visibility(
|
||||
u'#tab_{index} > .sequence-tooltip'.format(index=index),
|
||||
u'Tab {index} should appear'.format(index=index)
|
||||
)
|
||||
|
||||
@property
|
||||
def course_license(self):
|
||||
"""
|
||||
Returns the course license text, if present. Else returns None.
|
||||
"""
|
||||
element = self.q(css="#content .container-footer .course-license")
|
||||
if element.is_present():
|
||||
return element.text[0]
|
||||
return None
|
||||
|
||||
def go_to_sequential_position(self, sequential_position):
|
||||
"""
|
||||
Within a section/subsection navigate to the sequential position specified by `sequential_position`.
|
||||
@@ -148,38 +49,13 @@ class CoursewarePage(CoursePage, CompletionOnViewMixin):
|
||||
self.q(css=sequential_position_css).first.click()
|
||||
EmptyPromise(is_at_new_position, "Position navigation fulfilled").fulfill()
|
||||
|
||||
@property
|
||||
def sequential_position(self):
|
||||
"""
|
||||
Returns the position of the active tab in the sequence.
|
||||
"""
|
||||
tab_id = self._active_sequence_tab.attrs('id')[0]
|
||||
return int(tab_id.split('_')[1])
|
||||
|
||||
@property
|
||||
def _active_sequence_tab(self):
|
||||
return self.q(css='#sequence-list .nav-item.active')
|
||||
|
||||
@property
|
||||
def is_next_button_enabled(self):
|
||||
return not self.q(css='.sequence-nav > .sequence-nav-button.button-next.disabled').is_present()
|
||||
|
||||
@property
|
||||
def is_previous_button_enabled(self):
|
||||
return not self.q(css='.sequence-nav > .sequence-nav-button.button-previous.disabled').is_present()
|
||||
|
||||
def click_next_button_on_top(self):
|
||||
self._click_navigation_button('sequence-nav', 'button-next')
|
||||
|
||||
def click_next_button_on_bottom(self):
|
||||
self._click_navigation_button('sequence-bottom', 'button-next')
|
||||
|
||||
def click_previous_button_on_top(self):
|
||||
self._click_navigation_button('sequence-nav', 'button-previous')
|
||||
|
||||
def click_previous_button_on_bottom(self):
|
||||
self._click_navigation_button('sequence-bottom', 'button-previous')
|
||||
|
||||
def _click_navigation_button(self, top_or_bottom_class, next_or_previous_class):
|
||||
"""
|
||||
Clicks the navigation button, given the respective CSS classes.
|
||||
@@ -201,542 +77,3 @@ class CoursewarePage(CoursePage, CompletionOnViewMixin):
|
||||
css=u'.{} > .sequence-nav-button.{}'.format(top_or_bottom_class, next_or_previous_class)
|
||||
).first.click()
|
||||
EmptyPromise(is_at_new_tab_id, "Button navigation fulfilled").fulfill()
|
||||
|
||||
@property
|
||||
def can_start_proctored_exam(self):
|
||||
"""
|
||||
Returns True if the timed/proctored exam timer bar is visible on the courseware.
|
||||
"""
|
||||
return self.q(css='button.start-timed-exam[data-start-immediately="false"]').is_present()
|
||||
|
||||
def start_timed_exam(self):
|
||||
"""
|
||||
clicks the start this timed exam link
|
||||
"""
|
||||
self.q(css=".xblock-student_view .timed-exam .start-timed-exam").first.click()
|
||||
self.wait_for_element_presence(".proctored_exam_status .exam-timer", "Timer bar")
|
||||
|
||||
def stop_timed_exam(self):
|
||||
"""
|
||||
clicks the stop this timed exam link
|
||||
"""
|
||||
self.q(css=".proctored_exam_status button.exam-button-turn-in-exam").first.click()
|
||||
self.wait_for_element_absence(".proctored_exam_status .exam-button-turn-in-exam", "End Exam Button gone")
|
||||
self.wait_for_element_presence("button[name='submit-proctored-exam']", "Submit Exam Button")
|
||||
self.q(css="button[name='submit-proctored-exam']").first.click()
|
||||
self.wait_for_element_absence(".proctored_exam_status .exam-timer", "Timer bar")
|
||||
|
||||
def start_proctored_exam(self):
|
||||
"""
|
||||
clicks the start this timed exam link
|
||||
"""
|
||||
self.q(css='button.start-timed-exam[data-start-immediately="false"]').first.click()
|
||||
|
||||
# Wait for the unique exam code to appear.
|
||||
# self.wait_for_element_presence(".proctored-exam-code", "unique exam code")
|
||||
|
||||
def has_submitted_exam_message(self):
|
||||
"""
|
||||
Returns whether the "you have submitted your exam" message is present.
|
||||
This being true implies "the exam contents and results are hidden".
|
||||
"""
|
||||
return self.q(css="div.proctored-exam.completed").visible
|
||||
|
||||
def content_hidden_past_due_date(self):
|
||||
"""
|
||||
Returns whether the "the due date for this ___ has passed" message is present.
|
||||
___ is the type of the hidden content, and defaults to subsection.
|
||||
This being true implies "the ___ contents are hidden because their due date has passed".
|
||||
"""
|
||||
message = "this assignment is no longer available"
|
||||
if self.q(css="div.seq_content").is_present():
|
||||
return False
|
||||
for html in self.q(css="div.hidden-content").html:
|
||||
if message in html:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def entrance_exam_message_selector(self):
|
||||
"""
|
||||
Return the entrance exam status message selector on the top of courseware page.
|
||||
"""
|
||||
return self.q(css='#content .container section.course-content .sequential-status-message')
|
||||
|
||||
def has_entrance_exam_message(self):
|
||||
"""
|
||||
Returns boolean indicating presence entrance exam status message container div.
|
||||
"""
|
||||
return self.entrance_exam_message_selector.is_present()
|
||||
|
||||
def has_passed_message(self):
|
||||
"""
|
||||
Returns boolean indicating presence of passed message.
|
||||
"""
|
||||
return self.entrance_exam_message_selector.is_present() \
|
||||
and "You have passed the entrance exam" in self.entrance_exam_message_selector.text[0]
|
||||
|
||||
def has_banner(self):
|
||||
"""
|
||||
Returns boolean indicating presence of banner
|
||||
"""
|
||||
return self.q(css='.pattern-library-shim').is_present()
|
||||
|
||||
@property
|
||||
def is_timer_bar_present(self):
|
||||
"""
|
||||
Returns True if the timed/proctored exam timer bar is visible on the courseware.
|
||||
"""
|
||||
return self.q(css=".proctored_exam_status .exam-timer").is_present()
|
||||
|
||||
def active_usage_id(self):
|
||||
""" Returns the usage id of active sequence item """
|
||||
get_active = lambda el: 'active' in el.get_attribute('class')
|
||||
attribute_value = lambda el: el.get_attribute('data-id')
|
||||
return self.q(css='#sequence-list .nav-item').filter(get_active).map(attribute_value).results[0]
|
||||
|
||||
def unit_title_visible(self):
|
||||
""" Check if unit title is visible """
|
||||
return self.q(css='.unit-title').visible
|
||||
|
||||
def bookmark_button_visible(self):
|
||||
""" Check if bookmark button is visible """
|
||||
EmptyPromise(lambda: self.q(css='.bookmark-button').visible, "Bookmark button visible").fulfill()
|
||||
return True
|
||||
|
||||
@property
|
||||
def bookmark_button_state(self):
|
||||
""" Return `bookmarked` if button is in bookmarked state else '' """
|
||||
return 'bookmarked' if self.q(css='.bookmark-button.bookmarked').present else ''
|
||||
|
||||
@property
|
||||
def bookmark_icon_visible(self):
|
||||
""" Check if bookmark icon is visible on active sequence nav item """
|
||||
return self.q(css='.active .bookmark-icon').visible
|
||||
|
||||
def click_bookmark_unit_button(self):
|
||||
""" Bookmark a unit by clicking on Bookmark button """
|
||||
previous_state = self.bookmark_button_state
|
||||
self.q(css='.bookmark-button').first.click()
|
||||
EmptyPromise(lambda: self.bookmark_button_state != previous_state, "Bookmark button toggled").fulfill()
|
||||
|
||||
# TODO: TNL-6546: Remove this helper function
|
||||
def click_bookmarks_button(self):
|
||||
""" Click on Bookmarks button """
|
||||
self.q(css='.bookmarks-list-button').first.click()
|
||||
bookmarks_page = BookmarksPage(self.browser, self.course_id)
|
||||
bookmarks_page.visit()
|
||||
|
||||
def is_gating_banner_visible(self):
|
||||
"""
|
||||
Check if the gated banner for locked content is visible.
|
||||
"""
|
||||
return self.q(css='.problem-header').is_present() \
|
||||
and self.q(css='.btn-brand').text[0] == u'Go To Prerequisite Section' \
|
||||
and self.q(css='.problem-header').text[0] == u'Content Locked'
|
||||
|
||||
@property
|
||||
def is_word_cloud_rendered(self):
|
||||
"""
|
||||
Check for word cloud fields presence
|
||||
"""
|
||||
return self.q(css='.input-cloud').visible
|
||||
|
||||
def input_word_cloud(self, answer_word):
|
||||
"""
|
||||
Fill the word cloud fields
|
||||
|
||||
Args:
|
||||
answer_word(str): An answer words to be filled in the field
|
||||
"""
|
||||
self.wait_for_element_visibility('.input-cloud', "Word cloud fields are visible")
|
||||
css = u'.input_cloud_section label:nth-child({}) .input-cloud'
|
||||
for index in range(1, len(self.q(css='.input-cloud')) + 1):
|
||||
self.q(css=css.format(index)).fill(answer_word + str(index))
|
||||
|
||||
def save_word_cloud(self):
|
||||
"""
|
||||
Click save button
|
||||
"""
|
||||
self.q(css='.input_cloud_section .action button.save').click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
@property
|
||||
def word_cloud_answer_list(self):
|
||||
"""
|
||||
Get saved words
|
||||
|
||||
Returns:
|
||||
list: Return empty when no answer words are present
|
||||
list: Return populated when answer words are present
|
||||
"""
|
||||
|
||||
self.wait_for_element_presence('.your_words', "Answer list is present")
|
||||
if self.q(css='.your_words strong').present:
|
||||
return self.q(css='.your_words strong').text
|
||||
else:
|
||||
return self.q(css='.your_words').text[0]
|
||||
|
||||
def is_error_message_present(self):
|
||||
""" Check if LTI error is shown """
|
||||
return self.q(css=".error_message").is_present()
|
||||
|
||||
def is_iframe_present(self):
|
||||
""" Check if LTI iframe is present"""
|
||||
return self.q(css=".ltiLaunchFrame").is_present()
|
||||
|
||||
def is_launch_url_present(self):
|
||||
""" Check if LTI launch link is present"""
|
||||
return self.q(css=".link_lti_new_window").is_present()
|
||||
|
||||
def go_to_lti_container(self):
|
||||
""" Switch to LTI container"""
|
||||
self.scroll_to_element('.problem-header')
|
||||
iframe_name = self.q(css='.ltiLaunchFrame').attrs("name")[0]
|
||||
self.browser.switch_to.frame(iframe_name)
|
||||
|
||||
@property
|
||||
def get_role_selector(self):
|
||||
"""
|
||||
Find and return role selector
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'.action-preview-select',
|
||||
'Role selector element is available'
|
||||
)
|
||||
return self.q(css='.action-preview-select')
|
||||
|
||||
def get_elem_text(self, elem_css):
|
||||
"""
|
||||
Find the element with CSS selector given in the argument and return its text
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
elem_css,
|
||||
'problem score element is visible'
|
||||
)
|
||||
return self.q(css=elem_css).text[0]
|
||||
|
||||
def is_lti_component_present(self, elem_css):
|
||||
""" Check if LTI component element with given CSS selector is present"""
|
||||
return self.q(css=elem_css).is_present()
|
||||
|
||||
|
||||
class LTIContentIframe(CoursePage):
|
||||
"""
|
||||
LTIContentIframe info
|
||||
"""
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css=".result").present
|
||||
|
||||
def submit_lti_answer(self, button_css):
|
||||
""" Click on Submit button"""
|
||||
self.wait_for_element_presence(button_css, "Button element not present.")
|
||||
self.q(css=button_css).first.click()
|
||||
|
||||
@property
|
||||
def lti_content(self):
|
||||
""" return LTI content"""
|
||||
self.wait_for_element_presence(".result", "Result element not present.")
|
||||
content = self.q(css=".result").text[0]
|
||||
return content
|
||||
|
||||
@property
|
||||
def get_user_role(self):
|
||||
"""
|
||||
return logged in user role text from lti iframe contents(i.e staff or learner)
|
||||
"""
|
||||
return self.q(css="h5").text[0]
|
||||
|
||||
def switch_to_default(self):
|
||||
"""
|
||||
Switches to default page
|
||||
"""
|
||||
self.browser.switch_to_default_content()
|
||||
|
||||
|
||||
class CoursewareSequentialTabPage(CoursePage):
|
||||
"""
|
||||
Courseware Sequential page
|
||||
"""
|
||||
|
||||
def __init__(self, browser, course_id, chapter, subsection, position):
|
||||
super(CoursewareSequentialTabPage, self).__init__(browser, course_id)
|
||||
self.url_path = "courseware/{}/{}/{}".format(chapter, subsection, position)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='nav.sequence-list-wrapper').present
|
||||
|
||||
def get_selected_tab_content(self):
|
||||
"""
|
||||
return the body of the sequential currently selected
|
||||
"""
|
||||
return self.q(css='#seq_content .xblock').text[0]
|
||||
|
||||
|
||||
class CourseNavPage(PageObject):
|
||||
"""
|
||||
Handles navigation on the courseware pages, including sequence navigation and
|
||||
breadcrumbs.
|
||||
"""
|
||||
|
||||
url = None
|
||||
|
||||
def __init__(self, browser, parent_page):
|
||||
super(CourseNavPage, self).__init__(browser)
|
||||
self.parent_page = parent_page
|
||||
# TODO: TNL-6546: Remove the following
|
||||
self.course_outline_page = False
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.parent_page.is_browser_on_page
|
||||
|
||||
@property
|
||||
def breadcrumb_section_title(self):
|
||||
"""
|
||||
Returns the section's title from the breadcrumb, or None if one is not found.
|
||||
"""
|
||||
label = self.q(css='.breadcrumbs .nav-item-chapter').text
|
||||
return label[0].strip() if label else None
|
||||
|
||||
@property
|
||||
def breadcrumb_subsection_title(self):
|
||||
"""
|
||||
Returns the subsection's title from the breadcrumb, or None if one is not found
|
||||
"""
|
||||
label = self.q(css='.breadcrumbs .nav-item-section').text
|
||||
return label[0].strip() if label else None
|
||||
|
||||
@property
|
||||
def breadcrumb_unit_title(self):
|
||||
"""
|
||||
Returns the unit's title from the breadcrumb, or None if one is not found
|
||||
"""
|
||||
label = self.q(css='.breadcrumbs .nav-item-sequence').text
|
||||
return label[0].strip() if label else None
|
||||
|
||||
# TODO: TNL-6546: Remove method, outline no longer on courseware page
|
||||
@property
|
||||
def sections(self):
|
||||
"""
|
||||
Return a dictionary representation of sections and subsections.
|
||||
|
||||
Example:
|
||||
|
||||
{
|
||||
'Introduction': ['Course Overview'],
|
||||
'Week 1': ['Lesson 1', 'Lesson 2', 'Homework']
|
||||
'Final Exam': ['Final Exam']
|
||||
}
|
||||
|
||||
You can use these titles in `go_to_section` to navigate to the section.
|
||||
"""
|
||||
# Dict to store the result
|
||||
nav_dict = dict()
|
||||
|
||||
section_titles = self._section_titles()
|
||||
|
||||
# Get the section titles for each chapter
|
||||
for sec_index, sec_title in enumerate(section_titles):
|
||||
|
||||
if len(section_titles) < 1:
|
||||
self.warning(u"Could not find subsections for '{0}'".format(sec_title))
|
||||
else:
|
||||
# Add one to convert list index (starts at 0) to CSS index (starts at 1)
|
||||
nav_dict[sec_title] = self._subsection_titles(sec_index + 1)
|
||||
|
||||
return nav_dict
|
||||
|
||||
@property
|
||||
def sequence_items(self):
|
||||
"""
|
||||
Return a list of sequence items on the page.
|
||||
Sequence items are one level below subsections in the course nav.
|
||||
|
||||
Example return value:
|
||||
['Chemical Bonds Video', 'Practice Problems', 'Homework']
|
||||
"""
|
||||
seq_css = 'ol#sequence-list>li>.nav-item>.sequence-tooltip'
|
||||
return self.q(css=seq_css).map(self._clean_seq_titles).results
|
||||
|
||||
# TODO: TNL-6546: Remove method, outline no longer on courseware page
|
||||
def go_to_section(self, section_title, subsection_title):
|
||||
"""
|
||||
Go to the section in the courseware.
|
||||
Every section must have at least one subsection, so specify
|
||||
both the section and subsection title.
|
||||
|
||||
Example:
|
||||
go_to_section("Week 1", "Lesson 1")
|
||||
"""
|
||||
|
||||
# For test stability, disable JQuery animations (opening / closing menus)
|
||||
self.browser.execute_script("jQuery.fx.off = true;")
|
||||
|
||||
# Get the section by index
|
||||
try:
|
||||
sec_index = self._section_titles().index(section_title)
|
||||
except ValueError:
|
||||
self.warning(u"Could not find section '{0}'".format(section_title))
|
||||
return
|
||||
|
||||
# Click the section to ensure it's open (no harm in clicking twice if it's already open)
|
||||
# Add one to convert from list index to CSS index
|
||||
section_css = u'.course-navigation .chapter:nth-of-type({0})'.format(sec_index + 1)
|
||||
self.q(css=section_css).first.click()
|
||||
|
||||
# Get the subsection by index
|
||||
try:
|
||||
subsec_index = self._subsection_titles(sec_index + 1).index(subsection_title)
|
||||
except ValueError:
|
||||
msg = u"Could not find subsection '{0}' in section '{1}'".format(subsection_title, section_title)
|
||||
self.warning(msg)
|
||||
return
|
||||
|
||||
# Convert list indices (start at zero) to CSS indices (start at 1)
|
||||
subsection_css = (
|
||||
u".course-navigation .chapter-content-container:nth-of-type({0}) "
|
||||
".menu-item:nth-of-type({1})"
|
||||
).format(sec_index + 1, subsec_index + 1)
|
||||
|
||||
# Click the subsection and ensure that the page finishes reloading
|
||||
self.q(css=subsection_css).first.click()
|
||||
self._on_section_promise(section_title, subsection_title).fulfill()
|
||||
|
||||
def go_to_vertical(self, vertical_title):
|
||||
"""
|
||||
Within a section/subsection, navigate to the vertical with `vertical_title`.
|
||||
"""
|
||||
|
||||
# Get the index of the item in the sequence
|
||||
all_items = self.sequence_items
|
||||
|
||||
try:
|
||||
seq_index = all_items.index(vertical_title)
|
||||
|
||||
except ValueError:
|
||||
msg = u"Could not find sequential '{0}'. Available sequentials: [{1}]".format(
|
||||
vertical_title, ", ".join(all_items)
|
||||
)
|
||||
self.warning(msg)
|
||||
|
||||
else:
|
||||
|
||||
# Click on the sequence item at the correct index
|
||||
# Convert the list index (starts at 0) to a CSS index (starts at 1)
|
||||
seq_css = "ol#sequence-list>li:nth-of-type({0})>.nav-item".format(seq_index + 1)
|
||||
self.q(css=seq_css).first.click()
|
||||
# Click triggers an ajax event
|
||||
self.wait_for_ajax()
|
||||
|
||||
# TODO: TNL-6546: Remove method, outline no longer on courseware page
|
||||
def _section_titles(self):
|
||||
"""
|
||||
Return a list of all section titles on the page.
|
||||
"""
|
||||
chapter_css = '.course-navigation .chapter .group-heading'
|
||||
return self.q(css=chapter_css).map(lambda el: el.text.strip()).results
|
||||
|
||||
# TODO: TNL-6546: Remove method, outline no longer on courseware page
|
||||
def _subsection_titles(self, section_index):
|
||||
"""
|
||||
Return a list of all subsection titles on the page
|
||||
for the section at index `section_index` (starts at 1).
|
||||
"""
|
||||
# Retrieve the subsection title for the section
|
||||
# Add one to the list index to get the CSS index, which starts at one
|
||||
subsection_css = (
|
||||
u".course-navigation .chapter-content-container:nth-of-type({0}) "
|
||||
".menu-item a p:nth-of-type(1)"
|
||||
).format(section_index)
|
||||
|
||||
# If the element is visible, we can get its text directly
|
||||
# Otherwise, we need to get the HTML
|
||||
# It *would* make sense to always get the HTML, but unfortunately
|
||||
# the open tab has some child <span> tags that we don't want.
|
||||
return self.q(
|
||||
css=subsection_css
|
||||
).map(
|
||||
lambda el: el.text.strip().split('\n')[0] if el.is_displayed() else el.get_attribute('innerHTML').strip()
|
||||
).results
|
||||
|
||||
# TODO: TNL-6546: Remove method, outline no longer on courseware page
|
||||
def _on_section_promise(self, section_title, subsection_title):
|
||||
"""
|
||||
Return a `Promise` that is fulfilled when the user is on
|
||||
the correct section and subsection.
|
||||
"""
|
||||
desc = u"currently at section '{0}' and subsection '{1}'".format(section_title, subsection_title)
|
||||
return EmptyPromise(
|
||||
lambda: self.is_on_section(section_title, subsection_title), desc
|
||||
)
|
||||
|
||||
def go_to_outline(self):
|
||||
"""
|
||||
Navigates using breadcrumb to the course outline on the course home page.
|
||||
|
||||
Returns CourseHomePage page object.
|
||||
"""
|
||||
# To avoid circular dependency, importing inside the function
|
||||
from common.test.acceptance.pages.lms.course_home import CourseHomePage
|
||||
|
||||
course_home_page = CourseHomePage(self.browser, self.parent_page.course_id)
|
||||
self.q(css='.nav-item-course').click()
|
||||
course_home_page.wait_for_page()
|
||||
return course_home_page
|
||||
|
||||
@unguarded
|
||||
def is_on_section(self, section_title, subsection_title):
|
||||
"""
|
||||
Return a boolean indicating whether the user is on the section and subsection
|
||||
with the specified titles.
|
||||
"""
|
||||
return self.breadcrumb_section_title == section_title and self.breadcrumb_subsection_title == subsection_title
|
||||
|
||||
# Regular expression to remove HTML span tags from a string
|
||||
REMOVE_SPAN_TAG_RE = re.compile(r'</span>(.+)<span')
|
||||
|
||||
def _clean_seq_titles(self, element):
|
||||
"""
|
||||
Clean HTML of sequence titles, stripping out span tags and returning the first line.
|
||||
"""
|
||||
return self.REMOVE_SPAN_TAG_RE.search(element.get_attribute('innerHTML')).groups()[0].strip()
|
||||
|
||||
# TODO: TNL-6546: Remove. This is no longer needed.
|
||||
@property
|
||||
def active_subsection_url(self):
|
||||
"""
|
||||
return the url of the active subsection in the left nav
|
||||
"""
|
||||
return self.q(css='.chapter-content-container .menu-item.active a').attrs('href')[0]
|
||||
|
||||
# TODO: TNL-6546: Remove all references to self.course_outline_page
|
||||
# TODO: TNL-6546: Remove the following function
|
||||
def visit_course_outline_page(self):
|
||||
# use course_outline_page version of the nav
|
||||
self.course_outline_page = True
|
||||
# reload the same page with the course_outline_page flag
|
||||
self.browser.get(self.browser.current_url + "&course_experience.course_outline_page=1")
|
||||
self.wait_for_page()
|
||||
|
||||
|
||||
class RenderXBlockPage(PageObject, CompletionOnViewMixin):
|
||||
"""
|
||||
render_xblock page.
|
||||
"""
|
||||
|
||||
xblock_component_selector = '.xblock'
|
||||
|
||||
def __init__(self, browser, block_id):
|
||||
super(RenderXBlockPage, self).__init__(browser)
|
||||
self.block_id = block_id
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
Construct a URL to the page within the course.
|
||||
"""
|
||||
return BASE_URL + "/xblock/" + self.block_id
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='.course-content').present
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
Courseware search
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.pages.lms.course_page import CoursePage
|
||||
|
||||
|
||||
class CoursewareSearchPage(CoursePage):
|
||||
"""
|
||||
Coursware page featuring a search form
|
||||
"""
|
||||
|
||||
url_path = "courseware/"
|
||||
search_bar_selector = '#courseware-search-bar'
|
||||
search_results_selector = '.courseware-results'
|
||||
|
||||
@property
|
||||
def search_results(self):
|
||||
""" search results list showing """
|
||||
return self.q(css=self.search_results_selector)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
""" did we find the search bar in the UI """
|
||||
return self.q(css=self.search_bar_selector).present
|
||||
|
||||
def enter_search_term(self, text):
|
||||
""" enter the search term into the box """
|
||||
self.q(css=self.search_bar_selector + ' input[type="text"]').fill(text)
|
||||
|
||||
def search(self):
|
||||
""" execute the search """
|
||||
self.q(css=self.search_bar_selector + ' [type="submit"]').click()
|
||||
self.wait_for_ajax()
|
||||
self.wait_for_element_visibility(self.search_results_selector, 'Search results are visible')
|
||||
|
||||
def search_for_term(self, text):
|
||||
"""
|
||||
Fill input and do search
|
||||
"""
|
||||
self.enter_search_term(text)
|
||||
self.search()
|
||||
|
||||
def clear_search(self):
|
||||
"""
|
||||
Clear search bar after search.
|
||||
"""
|
||||
self.q(css=self.search_bar_selector + ' .cancel-button').click()
|
||||
self.wait_for_element_visibility('#course-content', 'Search bar is cleared')
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Mode creation page (used to add modes to courses during testing)."""
|
||||
|
||||
|
||||
import re
|
||||
|
||||
import six.moves.urllib.parse
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
|
||||
|
||||
class ModeCreationPage(PageObject):
|
||||
"""The mode creation page.
|
||||
|
||||
When allowed by the Django settings file, visiting this page allows modes to be
|
||||
created for an existing course.
|
||||
"""
|
||||
|
||||
def __init__(self, browser, course_id, mode_slug=None, mode_display_name=None, min_price=None,
|
||||
suggested_prices=None, currency=None, sku=None):
|
||||
"""The mode creation page is an endpoint for HTTP GET requests.
|
||||
|
||||
By default, it will create an 'honor' mode for the given course with display name
|
||||
'Honor Code', a minimum price of 0, no suggested prices, and using USD as the currency.
|
||||
|
||||
Arguments:
|
||||
browser (Browser): The browser instance.
|
||||
course_id (unicode): The ID of the course for which modes are to be created.
|
||||
|
||||
Keyword Arguments:
|
||||
mode_slug (str): The mode to add, either 'honor', 'verified', or 'professional'
|
||||
mode_display_name (str): Describes the new course mode
|
||||
min_price (int): The minimum price a user must pay to enroll in the new course mode
|
||||
suggested_prices (str): Comma-separated prices to suggest to the user.
|
||||
currency (str): The currency in which to list prices.
|
||||
sku (str): The product SKU value.
|
||||
"""
|
||||
super(ModeCreationPage, self).__init__(browser)
|
||||
|
||||
self._course_id = course_id
|
||||
self._parameters = {}
|
||||
|
||||
if mode_slug is not None:
|
||||
self._parameters['mode_slug'] = mode_slug
|
||||
|
||||
if mode_display_name is not None:
|
||||
self._parameters['mode_display_name'] = mode_display_name
|
||||
|
||||
if min_price is not None:
|
||||
self._parameters['min_price'] = min_price
|
||||
|
||||
if suggested_prices is not None:
|
||||
self._parameters['suggested_prices'] = suggested_prices
|
||||
|
||||
if currency is not None:
|
||||
self._parameters['currency'] = currency
|
||||
|
||||
if sku is not None:
|
||||
self._parameters['sku'] = sku
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""Construct the mode creation URL."""
|
||||
url = '{base}/course_modes/create_mode/{course_id}/'.format(
|
||||
base=BASE_URL,
|
||||
course_id=self._course_id
|
||||
)
|
||||
|
||||
query_string = six.moves.urllib.parse.urlencode(self._parameters)
|
||||
if query_string:
|
||||
url += '?' + query_string
|
||||
|
||||
return url
|
||||
|
||||
def is_browser_on_page(self):
|
||||
message = self.q(css='BODY').text[0]
|
||||
match = re.search(r'Mode ([^$]+) created for ([^$]+).$', message)
|
||||
return True if match else False
|
||||
@@ -5,8 +5,6 @@ Student dashboard page.
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from six.moves import range
|
||||
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
|
||||
@@ -21,223 +19,8 @@ class DashboardPage(PageObject):
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='.my-courses').present
|
||||
|
||||
@property
|
||||
def current_courses_text(self):
|
||||
"""
|
||||
This is the title label for the section of the student dashboard that
|
||||
shows all the courses that the student is enrolled in.
|
||||
The string displayed is defined in lms/templates/dashboard.html.
|
||||
"""
|
||||
text_items = self.q(css='#my-courses').text
|
||||
if len(text_items) > 0:
|
||||
return text_items[0]
|
||||
else:
|
||||
return ""
|
||||
|
||||
@property
|
||||
def available_courses(self):
|
||||
"""
|
||||
Return list of the names of available courses (e.g. "999 edX Demonstration Course")
|
||||
"""
|
||||
def _get_course_name(el):
|
||||
return el.text
|
||||
|
||||
return self.q(css='h3.course-title > a').map(_get_course_name).results
|
||||
|
||||
@property
|
||||
def banner_text(self):
|
||||
"""
|
||||
Return the text of the banner on top of the page, or None if
|
||||
the banner is not present.
|
||||
"""
|
||||
message = self.q(css='div.wrapper-msg')
|
||||
if message.present:
|
||||
return message.text[0]
|
||||
return None
|
||||
|
||||
def get_enrollment_mode(self, course_name):
|
||||
"""Get the enrollment mode for a given course on the dashboard.
|
||||
|
||||
Arguments:
|
||||
course_name (str): The name of the course whose mode should be retrieved.
|
||||
|
||||
Returns:
|
||||
String, indicating the enrollment mode for the course corresponding to
|
||||
the provided course name.
|
||||
|
||||
Raises:
|
||||
Exception, if no course with the provided name is found on the dashboard.
|
||||
"""
|
||||
# Filter elements by course name, only returning the relevant course item
|
||||
course_listing = self.q(css=".course").filter(lambda el: course_name in el.text).results
|
||||
|
||||
if course_listing:
|
||||
# There should only be one course listing for the provided course name.
|
||||
# Since 'ENABLE_VERIFIED_CERTIFICATES' is true in the Bok Choy settings, we
|
||||
# can expect two classes to be present on <article> elements, one being 'course'
|
||||
# and the other being the enrollment mode.
|
||||
enrollment_mode = course_listing[0].get_attribute('class').split('course ')[1]
|
||||
else:
|
||||
raise Exception(u"No course named {} was found on the dashboard".format(course_name))
|
||||
|
||||
return enrollment_mode
|
||||
|
||||
def view_course(self, course_id):
|
||||
"""
|
||||
Go to the course with `course_id` (e.g. edx/Open_DemoX/edx_demo_course)
|
||||
"""
|
||||
link_css = self._link_css(course_id)
|
||||
|
||||
if link_css is not None:
|
||||
self.q(css=link_css).first.click()
|
||||
else:
|
||||
msg = u"No links found for course {0}".format(course_id)
|
||||
self.warning(msg)
|
||||
|
||||
def _link_css(self, course_id):
|
||||
"""
|
||||
Return a CSS selector for the link to the course with `course_id`.
|
||||
"""
|
||||
# Get the link hrefs for all courses
|
||||
all_links = self.q(css='a.enter-course').map(lambda el: el.get_attribute('href')).results
|
||||
|
||||
# Search for the first link that matches the course id
|
||||
link_index = None
|
||||
for index in range(len(all_links)):
|
||||
if course_id in all_links[index]:
|
||||
link_index = index
|
||||
break
|
||||
|
||||
if link_index is not None:
|
||||
return "a.enter-course:nth-of-type({0})".format(link_index + 1)
|
||||
else:
|
||||
return None
|
||||
|
||||
def view_course_unenroll_dialog_message(self, course_id):
|
||||
"""
|
||||
Go to the course unenroll dialog message for `course_id` (e.g. edx/Open_DemoX/edx_demo_course)
|
||||
"""
|
||||
div_index = self.get_course_actions_link_css(course_id)
|
||||
button_link_css = "#actions-dropdown-link-{}".format(div_index)
|
||||
unenroll_css = "#unenroll-{}".format(div_index)
|
||||
|
||||
if button_link_css is not None:
|
||||
self.q(css=button_link_css).first.click()
|
||||
self.wait_for_element_visibility(unenroll_css, 'Unenroll message dialog is visible.')
|
||||
self.q(css=unenroll_css).first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
return {
|
||||
'track-info': self.q(css='#track-info').html,
|
||||
'refund-info': self.q(css='#refund-info').html
|
||||
}
|
||||
|
||||
else:
|
||||
msg = u"No links found for course {0}".format(course_id)
|
||||
self.warning(msg)
|
||||
|
||||
def get_course_actions_link_css(self, course_id):
|
||||
"""
|
||||
Return a index for unenroll button with `course_id`.
|
||||
"""
|
||||
# Get the link hrefs for all courses
|
||||
all_divs = self.q(css='div.wrapper-action-more').map(lambda el: el.get_attribute('data-course-key')).results
|
||||
|
||||
# Search for the first link that matches the course id
|
||||
div_index = None
|
||||
for index in range(len(all_divs)):
|
||||
if course_id in all_divs[index]:
|
||||
div_index = index
|
||||
break
|
||||
|
||||
return div_index
|
||||
|
||||
def pre_requisite_message_displayed(self):
|
||||
"""
|
||||
Verify if pre-requisite course messages are being displayed.
|
||||
"""
|
||||
return self.q(css='div.prerequisites > .tip').visible
|
||||
|
||||
def get_course_listings(self):
|
||||
"""Retrieve the list of course DOM elements"""
|
||||
return self.q(css='ul.listing-courses')
|
||||
|
||||
def get_course_social_sharing_widget(self, widget_name):
|
||||
""" Retrieves the specified social sharing widget by its classification """
|
||||
return self.q(css='a.action-{}'.format(widget_name))
|
||||
|
||||
def get_profile_img(self):
|
||||
""" Retrieves the user's profile image """
|
||||
return self.q(css='img.user-image-frame')
|
||||
|
||||
def get_courses(self):
|
||||
"""
|
||||
Get all courses shown in the dashboard
|
||||
"""
|
||||
return self.q(css='ul.listing-courses .course-item')
|
||||
|
||||
def get_course_date(self):
|
||||
"""
|
||||
Get course date of the first course from dashboard
|
||||
"""
|
||||
return self.q(css='ul.listing-courses .course-item:first-of-type .info-date-block').first.text[0]
|
||||
|
||||
def click_username_dropdown(self):
|
||||
"""
|
||||
Click username dropdown.
|
||||
"""
|
||||
self.q(css='.toggle-user-dropdown').first.click()
|
||||
|
||||
@property
|
||||
def username_dropdown_link_text(self):
|
||||
"""
|
||||
Return list username dropdown links.
|
||||
"""
|
||||
return self.q(css='.dropdown-user-menu a').text
|
||||
|
||||
@property
|
||||
def tabs_link_text(self):
|
||||
"""
|
||||
Return the text of all the tabs on the dashboard.
|
||||
"""
|
||||
return self.q(css='.nav-tab a').text
|
||||
|
||||
def click_my_profile_link(self):
|
||||
"""
|
||||
Click on `Profile` link.
|
||||
"""
|
||||
self.q(css='.nav-tab a').nth(1).click()
|
||||
|
||||
def click_account_settings_link(self):
|
||||
"""
|
||||
Click on `Account` link.
|
||||
"""
|
||||
self.q(css='.dropdown-user-menu a').nth(1).click()
|
||||
|
||||
@property
|
||||
def language_selector(self):
|
||||
"""
|
||||
return language selector
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'#settings-language-value',
|
||||
'Language selector element is available'
|
||||
)
|
||||
return self.q(css='#settings-language-value')
|
||||
|
||||
def is_course_present(self, course_id):
|
||||
"""
|
||||
Checks whether course is present or not.
|
||||
|
||||
Arguments:
|
||||
course_id(str): The unique course id.
|
||||
|
||||
Returns:
|
||||
bool: True if the course is present.
|
||||
"""
|
||||
course_number = CourseKey.from_string(course_id).course
|
||||
return self.q(
|
||||
css='#actions-dropdown-link-0[data-course-number="{}"]'.format(
|
||||
course_number
|
||||
)
|
||||
).present
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"""
|
||||
Course discovery page.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
|
||||
|
||||
class CourseDiscoveryPage(PageObject):
|
||||
"""
|
||||
Find courses page (main page of the LMS).
|
||||
"""
|
||||
|
||||
url = BASE_URL + "/courses"
|
||||
form = "#discovery-form"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Loading indicator must be present, but not visible
|
||||
"""
|
||||
loading_css = "#loading-indicator"
|
||||
courses_css = '.courses-listing'
|
||||
|
||||
return self.q(css=courses_css).visible \
|
||||
and self.q(css=loading_css).present \
|
||||
and not self.q(css=loading_css).visible
|
||||
|
||||
@property
|
||||
def result_items(self):
|
||||
"""
|
||||
Return search result items.
|
||||
"""
|
||||
return self.q(css=".courses-list .courses-listing-item")
|
||||
|
||||
@property
|
||||
def clear_button(self):
|
||||
"""
|
||||
Clear all button.
|
||||
"""
|
||||
return self.q(css="#clear-all-filters")
|
||||
|
||||
def search(self, string):
|
||||
"""
|
||||
Search and wait for ajax.
|
||||
"""
|
||||
self.q(css=self.form + ' input[type="text"]').fill(string)
|
||||
self.q(css=self.form + ' [type="submit"]').click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def clear_search(self):
|
||||
"""
|
||||
Clear search results.
|
||||
"""
|
||||
self.clear_button.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def click_course(self, course_id):
|
||||
"""
|
||||
Click on the course
|
||||
|
||||
Args:
|
||||
course_id(string): ID of the course which is to be clicked
|
||||
"""
|
||||
self.q(css='.courses-listing-item a').filter(lambda el: course_id in el.get_attribute('href')).click()
|
||||
@@ -5,70 +5,16 @@ LMS discussion page
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from bok_choy.javascript import wait_for_js
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise
|
||||
|
||||
from common.test.acceptance.pages.common.utils import hover
|
||||
from common.test.acceptance.pages.lms.course_page import CoursePage
|
||||
from common.test.acceptance.tests.helpers import is_focused_on_element
|
||||
|
||||
|
||||
class DiscussionPageMixin(object):
|
||||
|
||||
def is_ajax_finished(self):
|
||||
return self.browser.execute_script("return jQuery.active") == 0
|
||||
|
||||
def find_visible_element(self, selector):
|
||||
"""
|
||||
Finds a single visible element with the specified selector.
|
||||
"""
|
||||
full_selector = selector
|
||||
if self.root_selector:
|
||||
full_selector = self.root_selector + " " + full_selector
|
||||
elements = self.q(css=full_selector)
|
||||
return next((element for element in elements if element.is_displayed()), None)
|
||||
|
||||
@property
|
||||
def new_post_button(self):
|
||||
"""
|
||||
Returns the new post button if visible, else it returns None.
|
||||
"""
|
||||
return self.find_visible_element(".new-post-btn")
|
||||
|
||||
@property
|
||||
def new_post_form(self):
|
||||
"""
|
||||
Returns the new post form if visible, else it returns None.
|
||||
"""
|
||||
return self.find_visible_element(".forum-new-post-form")
|
||||
|
||||
def click_new_post_button(self):
|
||||
"""
|
||||
Clicks the 'New Post' button.
|
||||
"""
|
||||
self.wait_for(
|
||||
lambda: self.new_post_button,
|
||||
description="Waiting for new post button"
|
||||
)
|
||||
self.new_post_button.click()
|
||||
self.wait_for(
|
||||
lambda: self.new_post_form,
|
||||
description="Waiting for new post form"
|
||||
)
|
||||
|
||||
def click_cancel_new_post(self):
|
||||
"""
|
||||
Clicks the 'Cancel' button from the new post form.
|
||||
"""
|
||||
self.click_element(".cancel")
|
||||
self.wait_for(
|
||||
lambda: not self.new_post_form,
|
||||
"Waiting for new post form to close"
|
||||
)
|
||||
|
||||
|
||||
class DiscussionThreadPage(PageObject, DiscussionPageMixin):
|
||||
class DiscussionThreadPage(PageObject):
|
||||
"""
|
||||
Discussion thread page
|
||||
"""
|
||||
url = None
|
||||
|
||||
def __init__(self, browser, thread_selector):
|
||||
@@ -127,353 +73,6 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin):
|
||||
"Secondary action menu closed"
|
||||
).fulfill()
|
||||
|
||||
def get_group_visibility_label(self):
|
||||
"""
|
||||
Returns the group visibility label shown for the thread.
|
||||
"""
|
||||
return self._get_element_text(".group-visibility-label")
|
||||
|
||||
def get_response_total_text(self):
|
||||
"""Returns the response count text, or None if not present"""
|
||||
self.wait_for_ajax()
|
||||
return self._get_element_text(".response-count")
|
||||
|
||||
def get_num_displayed_responses(self):
|
||||
"""Returns the number of responses actually rendered"""
|
||||
return len(self._find_within(".discussion-response"))
|
||||
|
||||
def get_shown_responses_text(self):
|
||||
"""Returns the shown response count text, or None if not present"""
|
||||
return self._get_element_text(".response-display-count")
|
||||
|
||||
def get_load_responses_button_text(self):
|
||||
"""Returns the load more responses button text, or None if not present"""
|
||||
return self._get_element_text(".load-response-button")
|
||||
|
||||
def load_more_responses(self):
|
||||
"""Clicks the load more responses button and waits for responses to load"""
|
||||
self._find_within(".load-response-button").click()
|
||||
|
||||
EmptyPromise(
|
||||
self.is_ajax_finished,
|
||||
"Loading more Responses"
|
||||
).fulfill()
|
||||
|
||||
def has_add_response_button(self):
|
||||
"""Returns true if the add response button is visible, false otherwise"""
|
||||
return self.is_element_visible(".add-response-btn")
|
||||
|
||||
def has_discussion_reply_editor(self):
|
||||
"""
|
||||
Returns true if the discussion reply editor is is visible
|
||||
"""
|
||||
return self.is_element_visible(".discussion-reply-new")
|
||||
|
||||
def click_add_response_button(self):
|
||||
"""
|
||||
Clicks the add response button and ensures that the response text
|
||||
field receives focus
|
||||
"""
|
||||
self._find_within(".add-response-btn").first.click()
|
||||
EmptyPromise(
|
||||
lambda: self._find_within(".discussion-reply-new textarea:focus").present,
|
||||
"Response field received focus"
|
||||
).fulfill()
|
||||
|
||||
@wait_for_js
|
||||
def is_response_editor_visible(self, response_id):
|
||||
"""Returns true if the response editor is present, false otherwise"""
|
||||
return self.is_element_visible(u".response_{} .edit-post-body".format(response_id))
|
||||
|
||||
@wait_for_js
|
||||
def is_discussion_body_visible(self):
|
||||
return self.is_element_visible(".post-body")
|
||||
|
||||
def verify_mathjax_preview_available(self):
|
||||
""" Checks that MathJax Preview css class is present """
|
||||
self.wait_for(
|
||||
lambda: len(self.q(css=".MathJax_Preview").text) > 0 and self.q(css=".MathJax_Preview").text[0] == "",
|
||||
description="MathJax Preview is rendered"
|
||||
)
|
||||
|
||||
def verify_mathjax_rendered(self):
|
||||
""" Checks that MathJax css class is present """
|
||||
self.wait_for(
|
||||
lambda: self.is_element_visible(".MathJax_SVG"),
|
||||
description="MathJax Preview is rendered"
|
||||
)
|
||||
|
||||
def is_response_visible(self, comment_id):
|
||||
"""Returns true if the response is viewable onscreen"""
|
||||
self.wait_for_ajax()
|
||||
return self.is_element_visible(u".response_{} .response-body".format(comment_id))
|
||||
|
||||
def is_response_editable(self, response_id):
|
||||
"""Returns true if the edit response button is present, false otherwise"""
|
||||
with self.secondary_action_menu_open(u".response_{} .discussion-response".format(response_id)):
|
||||
return self.is_element_visible(u".response_{} .discussion-response .action-edit".format(response_id))
|
||||
|
||||
def is_response_deletable(self, response_id):
|
||||
"""
|
||||
Returns true if the delete response button is present, false otherwise
|
||||
"""
|
||||
with self.secondary_action_menu_open(u".response_{} .discussion-response".format(response_id)):
|
||||
return self.is_element_visible(u".response_{} .discussion-response .action-delete".format(response_id))
|
||||
|
||||
def get_response_body(self, response_id):
|
||||
return self._get_element_text(".response_{} .response-body".format(response_id))
|
||||
|
||||
def start_response_edit(self, response_id):
|
||||
"""Click the edit button for the response, loading the editing view"""
|
||||
with self.secondary_action_menu_open(u".response_{} .discussion-response".format(response_id)):
|
||||
self._find_within(u".response_{} .discussion-response .action-edit".format(response_id)).first.click()
|
||||
EmptyPromise(
|
||||
lambda: self.is_response_editor_visible(response_id),
|
||||
"Response edit started"
|
||||
).fulfill()
|
||||
|
||||
def get_link_href(self):
|
||||
"""Extracts href attribute of the referenced link"""
|
||||
link_href = self._find_within(".post-body p a").attrs('href')
|
||||
return link_href[0] if link_href else None
|
||||
|
||||
def get_response_vote_count(self, response_id):
|
||||
vote_count_css = '.response_{} .discussion-response .action-vote'.format(response_id)
|
||||
vote_count_element = self.browser.find_element_by_css_selector(vote_count_css)
|
||||
# To get the vote count, one must hover over the element first.
|
||||
hover(self.browser, vote_count_element)
|
||||
return self._get_element_text(u".response_{} .discussion-response .action-vote .vote-count".format(response_id))
|
||||
|
||||
def vote_response(self, response_id):
|
||||
current_count = self.get_response_vote_count(response_id)
|
||||
self._find_within(u".response_{} .discussion-response .action-vote".format(response_id)).first.click()
|
||||
self.wait_for(
|
||||
lambda: current_count != self.get_response_vote_count(response_id),
|
||||
description=u"Vote updated for {response_id}".format(response_id=response_id)
|
||||
)
|
||||
|
||||
def cannot_vote_response(self, response_id):
|
||||
"""Assert that the voting button is not visible on this response"""
|
||||
return not self.is_element_visible(u".response_{} .discussion-response .action-vote".format(response_id))
|
||||
|
||||
def is_response_reported(self, response_id):
|
||||
return self.is_element_visible(".response_{} .discussion-response .post-label-reported".format(response_id))
|
||||
|
||||
def report_response(self, response_id):
|
||||
with self.secondary_action_menu_open(".response_{} .discussion-response".format(response_id)):
|
||||
self._find_within(u".response_{} .discussion-response .action-report".format(response_id)).first.click()
|
||||
self.wait_for_ajax()
|
||||
EmptyPromise(
|
||||
lambda: self.is_response_reported(response_id),
|
||||
"Response is reported"
|
||||
).fulfill()
|
||||
|
||||
def cannot_report_response(self, response_id):
|
||||
"""Assert that the reporting button is not visible on this response"""
|
||||
return not self.is_element_visible(u".response_{} .discussion-response .action-report".format(response_id))
|
||||
|
||||
def is_response_endorsed(self, response_id):
|
||||
return "endorsed" in self._get_element_text(".response_{} .discussion-response .posted-details".format(response_id))
|
||||
|
||||
def endorse_response(self, response_id):
|
||||
self._find_within(".response_{} .discussion-response .action-endorse".format(response_id)).first.click()
|
||||
self.wait_for_ajax()
|
||||
EmptyPromise(
|
||||
lambda: self.is_response_endorsed(response_id),
|
||||
"Response edit started"
|
||||
).fulfill()
|
||||
|
||||
def set_response_editor_value(self, response_id, new_body):
|
||||
"""Replace the contents of the response editor"""
|
||||
self._find_within(u".response_{} .discussion-response .wmd-input".format(response_id)).fill(new_body)
|
||||
|
||||
def verify_link_editor_error_messages_shown(self):
|
||||
"""
|
||||
Confirm that the error messages are displayed in the editor.
|
||||
"""
|
||||
def errors_visible():
|
||||
"""
|
||||
Returns True if both errors are visible, False otherwise.
|
||||
"""
|
||||
return (
|
||||
self.q(css="#new-url-input-field-message.has-error").visible and
|
||||
self.q(css="#new-url-desc-input-field-message.has-error").visible
|
||||
)
|
||||
|
||||
self.wait_for(errors_visible, "Form errors should be visible.")
|
||||
|
||||
def add_content_via_editor_button(self, content_type, response_id, url, description, is_decorative=False):
|
||||
"""Replace the contents of the response editor"""
|
||||
self._find_within(
|
||||
"#wmd-{}-button-edit-post-body-{}".format(
|
||||
content_type,
|
||||
response_id,
|
||||
)
|
||||
).click()
|
||||
self.q(css='#new-url-input').fill(url)
|
||||
self.q(css='#new-url-desc-input').fill(description)
|
||||
|
||||
if is_decorative:
|
||||
self.q(css='#img-is-decorative').click()
|
||||
|
||||
self.q(css='input[value="OK"]').click()
|
||||
|
||||
def submit_response_edit(self, response_id, new_response_body):
|
||||
"""Click the submit button on the response editor"""
|
||||
|
||||
def submit_response_check_func():
|
||||
"""
|
||||
Tries to click "Update post" and returns True if the post
|
||||
was successfully updated, False otherwise.
|
||||
"""
|
||||
self._find_within(
|
||||
u".response_{} .discussion-response .post-update".format(
|
||||
response_id
|
||||
)
|
||||
).first.click()
|
||||
|
||||
return (
|
||||
not self.is_response_editor_visible(response_id) and
|
||||
self.is_response_visible(response_id) and
|
||||
self.get_response_body(response_id) == new_response_body
|
||||
)
|
||||
|
||||
self.wait_for(submit_response_check_func, "Comment edit succeeded")
|
||||
|
||||
def is_show_comments_visible(self, response_id):
|
||||
"""Returns true if the "show comments" link is visible for a response"""
|
||||
return self.is_element_visible(u".response_{} .action-show-comments".format(response_id))
|
||||
|
||||
def show_comments(self, response_id):
|
||||
"""Click the "show comments" link for a response"""
|
||||
self._find_within(u".response_{} .action-show-comments".format(response_id)).first.click()
|
||||
EmptyPromise(
|
||||
lambda: self.is_element_visible(u".response_{} .comments".format(response_id)),
|
||||
"Comments shown"
|
||||
).fulfill()
|
||||
|
||||
def is_add_comment_visible(self, response_id):
|
||||
"""Returns true if the "add comment" form is visible for a response"""
|
||||
return self.is_element_visible("#wmd-input-comment-body-{}".format(response_id))
|
||||
|
||||
def is_comment_visible(self, comment_id):
|
||||
"""Returns true if the comment is viewable onscreen"""
|
||||
return self.is_element_visible(u"#comment_{} .response-body".format(comment_id))
|
||||
|
||||
def get_comment_body(self, comment_id):
|
||||
return self._get_element_text("#comment_{} .response-body".format(comment_id))
|
||||
|
||||
def is_comment_deletable(self, comment_id):
|
||||
"""Returns true if the delete comment button is present, false otherwise"""
|
||||
with self.secondary_action_menu_open("#comment_{}".format(comment_id)):
|
||||
return self.is_element_visible(u"#comment_{} .action-delete".format(comment_id))
|
||||
|
||||
def delete_comment(self, comment_id):
|
||||
with self.handle_alert():
|
||||
with self.secondary_action_menu_open("#comment_{}".format(comment_id)):
|
||||
self._find_within(u"#comment_{} .action-delete".format(comment_id)).first.click()
|
||||
EmptyPromise(
|
||||
lambda: not self.is_comment_visible(comment_id),
|
||||
"Deleted comment was removed"
|
||||
).fulfill()
|
||||
|
||||
def is_comment_editable(self, comment_id):
|
||||
"""Returns true if the edit comment button is present, false otherwise"""
|
||||
with self.secondary_action_menu_open("#comment_{}".format(comment_id)):
|
||||
return self.is_element_visible(u"#comment_{} .action-edit".format(comment_id))
|
||||
|
||||
def is_comment_editor_visible(self, comment_id):
|
||||
"""Returns true if the comment editor is present, false otherwise"""
|
||||
return self.is_element_visible(".edit-comment-body[data-id='{}']".format(comment_id))
|
||||
|
||||
def _get_comment_editor_value(self, comment_id):
|
||||
return self._find_within("#wmd-input-edit-comment-body-{}".format(comment_id)).text[0]
|
||||
|
||||
def start_comment_edit(self, comment_id):
|
||||
"""Click the edit button for the comment, loading the editing view"""
|
||||
old_body = self.get_comment_body(comment_id)
|
||||
with self.secondary_action_menu_open("#comment_{}".format(comment_id)):
|
||||
self._find_within(u"#comment_{} .action-edit".format(comment_id)).first.click()
|
||||
EmptyPromise(
|
||||
lambda: (
|
||||
self.is_comment_editor_visible(comment_id) and
|
||||
not self.is_comment_visible(comment_id) and
|
||||
self._get_comment_editor_value(comment_id) == old_body
|
||||
),
|
||||
"Comment edit started"
|
||||
).fulfill()
|
||||
|
||||
def set_comment_editor_value(self, comment_id, new_body):
|
||||
"""Replace the contents of the comment editor"""
|
||||
self._find_within(u"#comment_{} .wmd-input".format(comment_id)).fill(new_body)
|
||||
|
||||
def submit_comment_edit(self, comment_id, new_comment_body):
|
||||
"""Click the submit button on the comment editor"""
|
||||
self._find_within(u"#comment_{} .post-update".format(comment_id)).first.click()
|
||||
self.wait_for_ajax()
|
||||
EmptyPromise(
|
||||
lambda: (
|
||||
not self.is_comment_editor_visible(comment_id) and
|
||||
self.is_comment_visible(comment_id) and
|
||||
self.get_comment_body(comment_id) == new_comment_body
|
||||
),
|
||||
"Comment edit succeeded"
|
||||
).fulfill()
|
||||
|
||||
def cancel_comment_edit(self, comment_id, original_body):
|
||||
"""Click the cancel button on the comment editor"""
|
||||
self._find_within(u"#comment_{} .post-cancel".format(comment_id)).first.click()
|
||||
EmptyPromise(
|
||||
lambda: (
|
||||
not self.is_comment_editor_visible(comment_id) and
|
||||
self.is_comment_visible(comment_id) and
|
||||
self.get_comment_body(comment_id) == original_body
|
||||
),
|
||||
"Comment edit was canceled"
|
||||
).fulfill()
|
||||
|
||||
|
||||
class DiscussionSortPreferencePage(CoursePage):
|
||||
"""
|
||||
Page that contain the discussion board with sorting options
|
||||
"""
|
||||
def __init__(self, browser, course_id):
|
||||
super(DiscussionSortPreferencePage, self).__init__(browser, course_id)
|
||||
self.url_path = "discussion/forum"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Return true if the browser is on the right page else false.
|
||||
"""
|
||||
return self.q(css="body.discussion .forum-nav-sort-control").present
|
||||
|
||||
def show_all_discussions(self):
|
||||
""" Show the list of all discussions. """
|
||||
self.q(css=".all-topics").click()
|
||||
|
||||
def get_selected_sort_preference(self):
|
||||
"""
|
||||
Return the text of option that is selected for sorting.
|
||||
"""
|
||||
# Using this workaround (execute script) to make this test work with Chrome browser
|
||||
selected_value = self.browser.execute_script(
|
||||
'var selected_value = $(".forum-nav-sort-control").val(); return selected_value')
|
||||
return selected_value
|
||||
|
||||
def change_sort_preference(self, sort_by):
|
||||
"""
|
||||
Change the option of sorting by clicking on new option.
|
||||
"""
|
||||
self.q(css=u".forum-nav-sort-control option[value='{0}']".format(sort_by)).click()
|
||||
# Click initiates an ajax call, waiting for it to complete
|
||||
self.wait_for_ajax()
|
||||
|
||||
def refresh_page(self):
|
||||
"""
|
||||
Reload the page.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
|
||||
|
||||
class DiscussionTabSingleThreadPage(CoursePage):
|
||||
def __init__(self, browser, course_id, discussion_id, thread_id):
|
||||
@@ -492,167 +91,15 @@ class DiscussionTabSingleThreadPage(CoursePage):
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.thread_page, name)
|
||||
|
||||
def show_all_discussions(self):
|
||||
""" Show the list of all discussions. """
|
||||
self.q(css=".all-topics").click()
|
||||
|
||||
def close_open_thread(self):
|
||||
with self.thread_page.secondary_action_menu_open(".thread-main-wrapper"):
|
||||
self._find_within(".thread-main-wrapper .action-close").first.click()
|
||||
|
||||
def _thread_is_rendered_successfully(self, thread_id):
|
||||
return self.q(css=".discussion-article[data-id='{}']".format(thread_id)).visible
|
||||
|
||||
def click_and_open_thread(self, thread_id):
|
||||
"""
|
||||
Click specific thread on the list.
|
||||
"""
|
||||
thread_selector = "li[data-id='{}']".format(thread_id)
|
||||
self.show_all_discussions()
|
||||
self.q(css=thread_selector).first.click()
|
||||
EmptyPromise(
|
||||
lambda: self._thread_is_rendered_successfully(thread_id),
|
||||
"Thread has been rendered"
|
||||
).fulfill()
|
||||
|
||||
def check_threads_rendered_successfully(self, thread_count):
|
||||
"""
|
||||
Count the number of threads available on page.
|
||||
"""
|
||||
return len(self.q(css=".forum-nav-thread").results) == thread_count
|
||||
|
||||
|
||||
class InlineDiscussionPage(PageObject, DiscussionPageMixin):
|
||||
class DiscussionTabHomePage(CoursePage):
|
||||
"""
|
||||
Acceptance tests for inline discussions.
|
||||
Discussion tab home page
|
||||
"""
|
||||
url = None
|
||||
|
||||
def __init__(self, browser, discussion_id):
|
||||
super(InlineDiscussionPage, self).__init__(browser)
|
||||
self.root_selector = (
|
||||
u".discussion-module[data-discussion-id='{discussion_id}'] ".format(
|
||||
discussion_id=discussion_id
|
||||
)
|
||||
)
|
||||
|
||||
def _find_within(self, selector):
|
||||
"""
|
||||
Returns a query corresponding to the given CSS selector within the scope
|
||||
of this discussion page
|
||||
"""
|
||||
return self.q(css=self.root_selector + " " + selector)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
self.wait_for_ajax()
|
||||
return self.q(css=self.root_selector).present
|
||||
|
||||
def is_discussion_expanded(self):
|
||||
return self._find_within(".discussion").present
|
||||
|
||||
def expand_discussion(self):
|
||||
"""Click the link to expand the discussion"""
|
||||
self._find_within(".discussion-show").first.click()
|
||||
EmptyPromise(
|
||||
self.is_discussion_expanded,
|
||||
"Discussion expanded"
|
||||
).fulfill()
|
||||
|
||||
def get_num_displayed_threads(self):
|
||||
return len(self._find_within(".forum-nav-thread"))
|
||||
|
||||
def element_exists(self, selector):
|
||||
return self.q(css=self.root_selector + " " + selector).present
|
||||
|
||||
def click_element(self, selector):
|
||||
self.wait_for_element_presence(
|
||||
u"{discussion} {selector}".format(discussion=self.root_selector, selector=selector),
|
||||
u"{selector} is visible".format(selector=selector)
|
||||
)
|
||||
self._find_within(selector).click()
|
||||
|
||||
def is_new_post_button_visible(self):
|
||||
"""
|
||||
Check if new post button present and visible
|
||||
"""
|
||||
return self._is_element_visible('.new-post-btn')
|
||||
|
||||
@wait_for_js
|
||||
def _is_element_visible(self, selector):
|
||||
query = self._find_within(selector)
|
||||
return query.present and query.visible
|
||||
|
||||
def show_thread(self, thread_id):
|
||||
"""
|
||||
Clicks the link for the specified thread to show the detailed view.
|
||||
"""
|
||||
self.wait_for_element_presence('.forum-nav-thread-link', 'Thread list has loaded')
|
||||
thread_selector = u".forum-nav-thread[data-id='{thread_id}'] .forum-nav-thread-link".format(thread_id=thread_id)
|
||||
self._find_within(thread_selector).first.click()
|
||||
self.thread_page = InlineDiscussionThreadPage(self.browser, thread_id) # pylint: disable=attribute-defined-outside-init
|
||||
self.thread_page.wait_for_page()
|
||||
|
||||
|
||||
class InlineDiscussionThreadPage(DiscussionThreadPage):
|
||||
"""
|
||||
Page object to manipulate an individual thread view in an inline discussion.
|
||||
"""
|
||||
def __init__(self, browser, thread_id):
|
||||
super(InlineDiscussionThreadPage, self).__init__(
|
||||
browser,
|
||||
u".discussion-module .discussion-article[data-id='{thread_id}']".format(thread_id=thread_id)
|
||||
)
|
||||
|
||||
def is_thread_anonymous(self):
|
||||
return not self.q(css=".posted-details > .username").present
|
||||
|
||||
@wait_for_js
|
||||
def check_if_selector_is_focused(self, selector):
|
||||
"""
|
||||
Check if selector is focused
|
||||
"""
|
||||
return is_focused_on_element(self.browser, selector)
|
||||
|
||||
|
||||
class DiscussionUserProfilePage(CoursePage):
|
||||
|
||||
TEXT_NEXT = u'Next >'
|
||||
TEXT_PREV = u'< Previous'
|
||||
PAGING_SELECTOR = ".discussion-pagination[data-page-number]"
|
||||
|
||||
def __init__(self, browser, course_id, user_id, username, page=1):
|
||||
super(DiscussionUserProfilePage, self).__init__(browser, course_id)
|
||||
self.url_path = "discussion/forum/dummy/users/{}?page={}".format(user_id, page)
|
||||
self.username = username
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return (
|
||||
self.q(css='.discussion-user-threads[data-course-id="{}"]'.format(self.course_id)).present
|
||||
and
|
||||
self.q(css='.user-name').present
|
||||
and
|
||||
self.q(css='.user-name').text[0] == self.username
|
||||
)
|
||||
|
||||
@wait_for_js
|
||||
def is_window_on_top(self):
|
||||
return self.browser.execute_script("return $('html, body').offset().top") == 0
|
||||
|
||||
def get_shown_thread_ids(self):
|
||||
elems = self.q(css="li.forum-nav-thread")
|
||||
return [elem.get_attribute("data-id") for elem in elems]
|
||||
|
||||
def click_on_sidebar_username(self):
|
||||
self.wait_for_page()
|
||||
self.q(css='.user-name').first.click()
|
||||
|
||||
def get_user_roles(self):
|
||||
"""Get user roles"""
|
||||
return self.q(css='.user-roles').text[0]
|
||||
|
||||
|
||||
class DiscussionTabHomePage(CoursePage, DiscussionPageMixin):
|
||||
|
||||
ALERT_SELECTOR = ".discussion-body .forum-nav .search-alert"
|
||||
|
||||
def __init__(self, browser, course_id):
|
||||
@@ -662,81 +109,3 @@ class DiscussionTabHomePage(CoursePage, DiscussionPageMixin):
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css=".discussion-body section.home-header").present
|
||||
|
||||
def perform_search(self, text="dummy"):
|
||||
self.q(css=".search-input").fill(text + chr(10))
|
||||
EmptyPromise(
|
||||
self.is_ajax_finished,
|
||||
"waiting for server to return result"
|
||||
).fulfill()
|
||||
|
||||
def is_element_visible(self, selector):
|
||||
"""
|
||||
Returns true if the element matching the specified selector is visible.
|
||||
"""
|
||||
query = self.q(css=selector)
|
||||
return query.present and query.visible
|
||||
|
||||
def is_checkbox_selected(self, selector):
|
||||
"""
|
||||
Returns true or false depending upon the matching checkbox is checked.
|
||||
"""
|
||||
return self.q(css=selector).selected
|
||||
|
||||
def refresh_and_wait_for_load(self):
|
||||
"""
|
||||
Refresh the page and wait for all resources to load.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
self.wait_for_page()
|
||||
|
||||
def get_search_alert_messages(self):
|
||||
return self.q(css=self.ALERT_SELECTOR + " .message").text
|
||||
|
||||
def get_search_alert_links(self):
|
||||
return self.q(css=self.ALERT_SELECTOR + " .link-jump")
|
||||
|
||||
def dismiss_alert_message(self, text):
|
||||
"""
|
||||
dismiss any search alert message containing the specified text.
|
||||
"""
|
||||
def _match_messages(text):
|
||||
return self.q(css=".search-alert").filter(lambda elem: text in elem.text)
|
||||
|
||||
for alert_id in _match_messages(text).attrs("id"):
|
||||
self.q(css=u"{}#{} .dismiss".format(self.ALERT_SELECTOR, alert_id)).click()
|
||||
EmptyPromise(
|
||||
lambda: _match_messages(text).results == [],
|
||||
"waiting for dismissed alerts to disappear"
|
||||
).fulfill()
|
||||
|
||||
def click_element(self, selector):
|
||||
"""
|
||||
Clicks the element specified by selector
|
||||
"""
|
||||
element = self.q(css=selector)
|
||||
return element.click()
|
||||
|
||||
def set_new_post_editor_value(self, new_body):
|
||||
"""
|
||||
Set the Discussions new post editor (wmd) with the content in new_body
|
||||
"""
|
||||
self.q(css=".wmd-input").fill(new_body)
|
||||
|
||||
def get_new_post_preview_value(self, selector=".wmd-preview > *"):
|
||||
"""
|
||||
Get the rendered preview of the contents of the Discussions new post editor
|
||||
Waits for content to appear, as the preview is triggered on debounced/delayed onchange
|
||||
"""
|
||||
self.scroll_to_element(selector)
|
||||
self.wait_for_element_visibility(selector, "WMD preview pane has contents", timeout=10)
|
||||
return self.q(css=".wmd-preview").html[0]
|
||||
|
||||
def get_new_post_preview_text(self, selector=".wmd-preview > div"):
|
||||
"""
|
||||
Get the rendered preview of the contents of the Discussions new post editor
|
||||
Waits for content to appear, as the preview is triggered on debounced/delayed onchange
|
||||
"""
|
||||
self.scroll_to_element(selector)
|
||||
self.wait_for_element_visibility(selector, "WMD preview pane has contents", timeout=10)
|
||||
return self.q(css=".wmd-preview").text[0]
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
"""
|
||||
LMS edxnotes page
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageLoadError, PageObject, unguarded
|
||||
from bok_choy.promise import BrokenPromise, EmptyPromise
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
|
||||
from common.test.acceptance.pages.common.paging import PaginatedUIMixin
|
||||
from common.test.acceptance.pages.lms.course_page import CoursePage
|
||||
from common.test.acceptance.tests.helpers import disable_animations
|
||||
|
||||
|
||||
class NoteChild(PageObject):
|
||||
url = None
|
||||
BODY_SELECTOR = None
|
||||
|
||||
def __init__(self, browser, item_id):
|
||||
super(NoteChild, self).__init__(browser)
|
||||
self.item_id = item_id
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css="{}#{}".format(self.BODY_SELECTOR, self.item_id)).present
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular `NoteChild` context
|
||||
"""
|
||||
return u"{}#{} {}".format(
|
||||
self.BODY_SELECTOR,
|
||||
self.item_id,
|
||||
selector,
|
||||
)
|
||||
|
||||
def _get_element_text(self, selector):
|
||||
element = self.q(css=self._bounded_selector(selector)).first
|
||||
if element:
|
||||
return element.text[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class EdxNotesChapterGroup(NoteChild):
|
||||
"""
|
||||
Helper class that works with chapter (section) grouping of notes in the Course Structure view on the Note page.
|
||||
"""
|
||||
BODY_SELECTOR = ".note-group"
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self._get_element_text(".course-title")
|
||||
|
||||
@property
|
||||
def subtitles(self):
|
||||
return [section.title for section in self.children]
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
children = self.q(css=self._bounded_selector('.note-section'))
|
||||
return [EdxNotesSubsectionGroup(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
|
||||
class EdxNotesGroupMixin(object):
|
||||
"""
|
||||
Helper mixin that works with note groups (used for subsection and tag groupings).
|
||||
"""
|
||||
@property
|
||||
def title(self):
|
||||
return self._get_element_text(self.TITLE_SELECTOR)
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
children = self.q(css=self._bounded_selector('.note'))
|
||||
return [EdxNotesPageItem(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
return [section.text for section in self.children]
|
||||
|
||||
|
||||
class EdxNotesSubsectionGroup(NoteChild, EdxNotesGroupMixin):
|
||||
"""
|
||||
Helper class that works with subsection grouping of notes in the Course Structure view on the Note page.
|
||||
"""
|
||||
BODY_SELECTOR = ".note-section"
|
||||
TITLE_SELECTOR = ".course-subtitle"
|
||||
|
||||
|
||||
class EdxNotesTagsGroup(NoteChild, EdxNotesGroupMixin):
|
||||
"""
|
||||
Helper class that works with tags grouping of notes in the Tags view on the Note page.
|
||||
"""
|
||||
BODY_SELECTOR = ".note-group"
|
||||
TITLE_SELECTOR = ".tags-title"
|
||||
|
||||
def scrolled_to_top(self, group_index):
|
||||
"""
|
||||
Returns True if the group with supplied group)index is scrolled near the top of the page
|
||||
(expects 10 px padding).
|
||||
|
||||
The group_index must be supplied because JQuery must be used to get this information, and it
|
||||
does not have access to the bounded selector.
|
||||
"""
|
||||
title_selector = "$('" + self.TITLE_SELECTOR + "')[" + str(group_index) + "]"
|
||||
top_script = "return " + title_selector + ".getBoundingClientRect().top;"
|
||||
EmptyPromise(
|
||||
lambda: 8 < self.browser.execute_script(top_script) < 12,
|
||||
u"Expected tag title '{}' to scroll to top, but was at location {}".format(
|
||||
self.title, self.browser.execute_script(top_script)
|
||||
)
|
||||
).fulfill()
|
||||
# Now also verify that focus has moved to this title (for screen readers):
|
||||
active_script = "return " + title_selector + " === document.activeElement;"
|
||||
return self.browser.execute_script(active_script)
|
||||
|
||||
|
||||
class EdxNotesPageItem(NoteChild):
|
||||
"""
|
||||
Helper class that works with note items on Note page of the course.
|
||||
"""
|
||||
BODY_SELECTOR = ".note"
|
||||
UNIT_LINK_SELECTOR = "a.reference-unit-link"
|
||||
TAG_SELECTOR = "span.reference-tags"
|
||||
|
||||
def go_to_unit(self, unit_page=None):
|
||||
self.q(css=self._bounded_selector(self.UNIT_LINK_SELECTOR)).click()
|
||||
if unit_page is not None:
|
||||
unit_page.wait_for_page()
|
||||
|
||||
@property
|
||||
def unit_name(self):
|
||||
return self._get_element_text(self.UNIT_LINK_SELECTOR)
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return self._get_element_text(".note-comment-p")
|
||||
|
||||
@property
|
||||
def quote(self):
|
||||
return self._get_element_text(".note-excerpt")
|
||||
|
||||
@property
|
||||
def time_updated(self):
|
||||
return self._get_element_text(".reference-updated-date")
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
""" The tags associated with this note. """
|
||||
tag_links = self.q(css=self._bounded_selector(self.TAG_SELECTOR))
|
||||
if len(tag_links) == 0:
|
||||
return None
|
||||
return[tag_link.text for tag_link in tag_links]
|
||||
|
||||
def go_to_tag(self, tag_name):
|
||||
""" Clicks a tag associated with the note to change to the tags view (and scroll to the tag group). """
|
||||
self.q(css=self._bounded_selector(self.TAG_SELECTOR)).filter(lambda el: tag_name in el.text).click()
|
||||
|
||||
|
||||
class EdxNotesPageView(PageObject):
|
||||
"""
|
||||
Base class for EdxNotes views: Recent Activity, Location in Course, Search Results.
|
||||
"""
|
||||
url = None
|
||||
BODY_SELECTOR = ".tab-panel"
|
||||
TAB_SELECTOR = ".tab"
|
||||
CHILD_SELECTOR = ".note"
|
||||
CHILD_CLASS = EdxNotesPageItem
|
||||
|
||||
@unguarded
|
||||
def visit(self):
|
||||
"""
|
||||
Open the page containing this page object in the browser.
|
||||
|
||||
Raises:
|
||||
PageLoadError: The page did not load successfully.
|
||||
|
||||
Returns:
|
||||
PageObject
|
||||
"""
|
||||
self.q(css=self.TAB_SELECTOR).first.click()
|
||||
try:
|
||||
return self.wait_for_page()
|
||||
except BrokenPromise:
|
||||
raise PageLoadError(u"Timed out waiting to load page '{!r}'".format(self))
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return all([
|
||||
self.q(css="{}".format(self.BODY_SELECTOR)).present,
|
||||
self.q(css="{}.is-active".format(self.TAB_SELECTOR)).present,
|
||||
not self.q(css=".ui-loading").visible,
|
||||
])
|
||||
|
||||
@property
|
||||
def is_closable(self):
|
||||
"""
|
||||
Indicates if tab is closable or not.
|
||||
"""
|
||||
return self.q(css=u"{} .action-close".format(self.TAB_SELECTOR)).present
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Closes the tab.
|
||||
"""
|
||||
self.q(css=u"{} .action-close".format(self.TAB_SELECTOR)).first.click()
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
"""
|
||||
Returns all notes on the page.
|
||||
"""
|
||||
children = self.q(css=self.CHILD_SELECTOR)
|
||||
return [self.CHILD_CLASS(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
|
||||
class RecentActivityView(EdxNotesPageView):
|
||||
"""
|
||||
Helper class for Recent Activity view.
|
||||
"""
|
||||
BODY_SELECTOR = "#recent-panel"
|
||||
TAB_SELECTOR = ".tab#view-recent-activity"
|
||||
|
||||
|
||||
class CourseStructureView(EdxNotesPageView):
|
||||
"""
|
||||
Helper class for Location in Course view.
|
||||
"""
|
||||
BODY_SELECTOR = "#structure-panel"
|
||||
TAB_SELECTOR = ".tab#view-course-structure"
|
||||
CHILD_SELECTOR = ".note-group"
|
||||
CHILD_CLASS = EdxNotesChapterGroup
|
||||
|
||||
|
||||
class TagsView(EdxNotesPageView):
|
||||
"""
|
||||
Helper class for Tags view.
|
||||
"""
|
||||
BODY_SELECTOR = "#tags-panel"
|
||||
TAB_SELECTOR = ".tab#view-tags"
|
||||
CHILD_SELECTOR = ".note-group"
|
||||
CHILD_CLASS = EdxNotesTagsGroup
|
||||
|
||||
|
||||
class SearchResultsView(EdxNotesPageView):
|
||||
"""
|
||||
Helper class for Search Results view.
|
||||
"""
|
||||
BODY_SELECTOR = "#search-results-panel"
|
||||
TAB_SELECTOR = ".tab#view-search-results"
|
||||
|
||||
|
||||
class EdxNotesPage(CoursePage, PaginatedUIMixin):
|
||||
"""
|
||||
EdxNotes page.
|
||||
"""
|
||||
url_path = "edxnotes/"
|
||||
MAPPING = {
|
||||
"recent": RecentActivityView,
|
||||
"structure": CourseStructureView,
|
||||
"tags": TagsView,
|
||||
"search": SearchResultsView,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(EdxNotesPage, self).__init__(*args, **kwargs)
|
||||
self.current_view = self.MAPPING["recent"](self.browser)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css=".wrapper-student-notes .note-group").visible
|
||||
|
||||
def switch_to_tab(self, tab_name):
|
||||
"""
|
||||
Switches to the appropriate tab `tab_name(str)`.
|
||||
"""
|
||||
self.current_view = self.MAPPING[tab_name](self.browser)
|
||||
self.current_view.visit()
|
||||
|
||||
def close_tab(self):
|
||||
"""
|
||||
Closes the current view.
|
||||
"""
|
||||
self.current_view.close()
|
||||
self.current_view = self.MAPPING["recent"](self.browser)
|
||||
|
||||
def search(self, text):
|
||||
"""
|
||||
Runs search with `text(str)` query.
|
||||
"""
|
||||
self.q(css="#search-notes-form #search-notes-input").first.fill(text)
|
||||
self.q(css='#search-notes-form .search-notes-submit').first.click()
|
||||
# Frontend will automatically switch to Search results tab when search
|
||||
# is running, so the view also needs to be changed.
|
||||
self.current_view = self.MAPPING["search"](self.browser)
|
||||
if text.strip():
|
||||
self.current_view.wait_for_page()
|
||||
|
||||
@property
|
||||
def tabs(self):
|
||||
"""
|
||||
Returns all tabs on the page.
|
||||
"""
|
||||
tabs = self.q(css=".tabs .tab-label")
|
||||
if tabs:
|
||||
return [x.replace("Current tab\n", "") for x in tabs.text]
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_error_visible(self):
|
||||
"""
|
||||
Indicates whether error message is visible or not.
|
||||
"""
|
||||
return self.q(css=".inline-error").visible
|
||||
|
||||
@property
|
||||
def error_text(self):
|
||||
"""
|
||||
Returns error message.
|
||||
"""
|
||||
element = self.q(css=".inline-error").first
|
||||
if element and self.is_error_visible:
|
||||
return element.text[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
"""
|
||||
Returns all notes on the page.
|
||||
"""
|
||||
children = self.q(css='.note')
|
||||
return [EdxNotesPageItem(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def chapter_groups(self):
|
||||
"""
|
||||
Returns all chapter groups on the page.
|
||||
"""
|
||||
children = self.q(css='.note-group')
|
||||
return [EdxNotesChapterGroup(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def subsection_groups(self):
|
||||
"""
|
||||
Returns all subsection groups on the page.
|
||||
"""
|
||||
children = self.q(css='.note-section')
|
||||
return [EdxNotesSubsectionGroup(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
@property
|
||||
def tag_groups(self):
|
||||
"""
|
||||
Returns all tag groups on the page.
|
||||
"""
|
||||
children = self.q(css='.note-group')
|
||||
return [EdxNotesTagsGroup(self.browser, child.get_attribute("id")) for child in children]
|
||||
|
||||
def count(self):
|
||||
""" Returns the total number of notes in the list """
|
||||
return len(self.q(css='div.wrapper-note-excerpts').results)
|
||||
|
||||
|
||||
class EdxNoteHighlight(NoteChild):
|
||||
"""
|
||||
Helper class that works with notes.
|
||||
"""
|
||||
BODY_SELECTOR = ""
|
||||
ADDER_SELECTOR = ".annotator-adder"
|
||||
VIEWER_SELECTOR = ".annotator-viewer"
|
||||
EDITOR_SELECTOR = ".annotator-editor"
|
||||
NOTE_SELECTOR = ".annotator-note"
|
||||
|
||||
def __init__(self, browser, element, parent_id):
|
||||
super(EdxNoteHighlight, self).__init__(browser, parent_id)
|
||||
self.element = element
|
||||
self.item_id = parent_id
|
||||
disable_animations(self)
|
||||
|
||||
@property
|
||||
def is_visible(self):
|
||||
"""
|
||||
Returns True if the note is visible.
|
||||
"""
|
||||
viewer_is_visible = self.q(css=self._bounded_selector(self.VIEWER_SELECTOR)).visible
|
||||
editor_is_visible = self.q(css=self._bounded_selector(self.EDITOR_SELECTOR)).visible
|
||||
return viewer_is_visible or editor_is_visible
|
||||
|
||||
def wait_for_adder_visibility(self):
|
||||
"""
|
||||
Waiting for visibility of note adder button.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
self._bounded_selector(self.ADDER_SELECTOR), "Adder is visible."
|
||||
)
|
||||
|
||||
def wait_for_viewer_visibility(self):
|
||||
"""
|
||||
Waiting for visibility of note viewer.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
self._bounded_selector(self.VIEWER_SELECTOR), "Note Viewer is visible."
|
||||
)
|
||||
|
||||
def wait_for_editor_visibility(self):
|
||||
"""
|
||||
Waiting for visibility of note editor.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
self._bounded_selector(self.EDITOR_SELECTOR), "Note Editor is visible."
|
||||
)
|
||||
|
||||
def wait_for_notes_invisibility(self, text="Notes are hidden"):
|
||||
"""
|
||||
Waiting for invisibility of all notes.
|
||||
"""
|
||||
selector = self._bounded_selector(".annotator-outer")
|
||||
self.wait_for_element_invisibility(selector, text)
|
||||
|
||||
def select_and_click_adder(self):
|
||||
"""
|
||||
Creates selection for the element and clicks `add note` button.
|
||||
"""
|
||||
ActionChains(self.browser).double_click(self.element).perform()
|
||||
self.wait_for_adder_visibility()
|
||||
self.q(css=self._bounded_selector(self.ADDER_SELECTOR)).first.click()
|
||||
self.wait_for_editor_visibility()
|
||||
return self
|
||||
|
||||
def click_on_highlight(self):
|
||||
"""
|
||||
Clicks on the highlighted text.
|
||||
"""
|
||||
ActionChains(self.browser).move_to_element(self.element).click().perform()
|
||||
return self
|
||||
|
||||
def click_on_viewer(self):
|
||||
"""
|
||||
Clicks on the note viewer.
|
||||
"""
|
||||
self.q(css=self.NOTE_SELECTOR).first.click()
|
||||
return self
|
||||
|
||||
def show(self):
|
||||
"""
|
||||
Hover over highlighted text -> shows note.
|
||||
"""
|
||||
ActionChains(self.browser).move_to_element(self.element).perform()
|
||||
self.wait_for_viewer_visibility()
|
||||
return self
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Clicks cancel button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-close")).first.click()
|
||||
self.wait_for_notes_invisibility("Note is canceled.")
|
||||
return self
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-save")).first.click()
|
||||
self.wait_for_notes_invisibility("Note is saved.")
|
||||
self.wait_for_ajax()
|
||||
return self
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Clicks delete button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-delete")).first.click()
|
||||
self.wait_for_notes_invisibility("Note is removed.")
|
||||
self.wait_for_ajax()
|
||||
return self
|
||||
|
||||
def edit(self):
|
||||
"""
|
||||
Clicks edit button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-edit")).first.click()
|
||||
self.wait_for_editor_visibility()
|
||||
return self
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
"""
|
||||
Returns text of the note.
|
||||
"""
|
||||
self.show()
|
||||
element = self.q(css=self._bounded_selector(".annotator-annotation > div.annotator-note"))
|
||||
if element:
|
||||
text = element.text[0].strip()
|
||||
else:
|
||||
text = None
|
||||
self.cancel()
|
||||
return text
|
||||
|
||||
@text.setter
|
||||
def text(self, value):
|
||||
"""
|
||||
Sets text for the note.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-item textarea")).first.fill(value)
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
"""
|
||||
Returns the tags associated with the note.
|
||||
|
||||
Tags are returned as a list of strings, with each tag as an individual string.
|
||||
"""
|
||||
tag_text = []
|
||||
self.show()
|
||||
tags = self.q(css=self._bounded_selector(".annotator-annotation > div.annotator-tags > span.annotator-tag"))
|
||||
if tags:
|
||||
for tag in tags:
|
||||
tag_text.append(tag.text)
|
||||
self.cancel()
|
||||
return tag_text
|
||||
|
||||
@tags.setter
|
||||
def tags(self, tags):
|
||||
"""
|
||||
Sets tags for the note. Tags should be supplied as a list of strings, with each tag as an individual string.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".annotator-item input")).first.fill(" ".join(tags))
|
||||
|
||||
def has_sr_label(self, sr_index, field_index, expected_text):
|
||||
"""
|
||||
Returns true iff a screen reader label (of index sr_index) exists for the annotator field with
|
||||
the specified field_index and text.
|
||||
"""
|
||||
label_exists = False
|
||||
EmptyPromise(
|
||||
lambda: len(self.q(css=self._bounded_selector("li.annotator-item > label.sr"))) > sr_index,
|
||||
u"Expected more than '{}' sr labels".format(sr_index)
|
||||
).fulfill()
|
||||
annotator_field_label = self.q(css=self._bounded_selector("li.annotator-item > label.sr"))[sr_index]
|
||||
for_attrib_correct = annotator_field_label.get_attribute("for") == "annotator-field-" + str(field_index)
|
||||
if for_attrib_correct and (annotator_field_label.text == expected_text):
|
||||
label_exists = True
|
||||
|
||||
self.q(css="body").first.click()
|
||||
self.wait_for_notes_invisibility()
|
||||
|
||||
return label_exists
|
||||
@@ -1,53 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LMS index (home) page.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
|
||||
BANNER_SELECTOR = 'section.home header div.outer-wrapper div.title .heading-group h1'
|
||||
INTRO_VIDEO_SELECTOR = 'div.play-intro'
|
||||
VIDEO_MODAL_SELECTOR = 'section#video-modal.modal.home-page-video-modal.video-modal'
|
||||
|
||||
|
||||
class IndexPage(PageObject):
|
||||
"""
|
||||
LMS index (home) page, the default landing page for Open edX users when they are not logged in
|
||||
"""
|
||||
url = "{base}/".format(base=BASE_URL)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Returns a browser query object representing the video modal element
|
||||
"""
|
||||
element = self.q(css=BANNER_SELECTOR)
|
||||
return element.visible and element.text[0].startswith("Welcome to ")
|
||||
|
||||
@property
|
||||
def banner_element(self):
|
||||
"""
|
||||
Returns a browser query object representing the video modal element
|
||||
"""
|
||||
return self.q(css=BANNER_SELECTOR)
|
||||
|
||||
@property
|
||||
def intro_video_element(self):
|
||||
"""
|
||||
Returns a browser query object representing the video modal element
|
||||
"""
|
||||
return self.q(css=INTRO_VIDEO_SELECTOR)
|
||||
|
||||
@property
|
||||
def video_modal_element(self):
|
||||
"""
|
||||
Returns a browser query object representing the video modal element
|
||||
"""
|
||||
return self.q(css=VIDEO_MODAL_SELECTOR)
|
||||
|
||||
@property
|
||||
def footer_links(self):
|
||||
"""Return a list of the text of the links in the page footer."""
|
||||
return self.q(css='.nav-colophon a').attrs('text')
|
||||
@@ -5,9 +5,7 @@ Instructor (2) dashboard page.
|
||||
|
||||
|
||||
import os
|
||||
import os.path
|
||||
|
||||
import six
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise, Promise
|
||||
|
||||
@@ -50,41 +48,6 @@ class InstructorDashboardPage(CoursePage):
|
||||
cohort_management_section.wait_for_page()
|
||||
return cohort_management_section
|
||||
|
||||
def select_discussion_management(self):
|
||||
"""
|
||||
Selects the Discussion tab and returns the DiscussionmanagementSection
|
||||
"""
|
||||
self.q(css='[data-section="discussions_management"').first.click()
|
||||
discussion_management_section = DiscussionManagementSection(self.browser)
|
||||
discussion_management_section.wait_for_page()
|
||||
return discussion_management_section
|
||||
|
||||
def is_discussion_management_visible(self):
|
||||
"""
|
||||
Is the Discussion tab visible
|
||||
"""
|
||||
return self.q(css='[data-section="discussions_management"').visible
|
||||
|
||||
def select_data_download(self):
|
||||
"""
|
||||
Selects the data download tab and returns a DataDownloadPage.
|
||||
"""
|
||||
self.q(css='[data-section="data_download"]').first.click()
|
||||
data_download_section = DataDownloadPage(self.browser)
|
||||
data_download_section.wait_for_page()
|
||||
return data_download_section
|
||||
|
||||
def select_student_admin(self, admin_class):
|
||||
"""
|
||||
Selects the student admin tab and returns the requested
|
||||
admin section.
|
||||
admin_class should be a subclass of StudentAdminPage.
|
||||
"""
|
||||
self.q(css='[data-section="student_admin"]').first.click()
|
||||
student_admin_section = admin_class(self.browser)
|
||||
student_admin_section.wait_for_page()
|
||||
return student_admin_section
|
||||
|
||||
def select_certificates(self):
|
||||
"""
|
||||
Selects the certificates tab and returns the CertificatesSection
|
||||
@@ -94,15 +57,6 @@ class InstructorDashboardPage(CoursePage):
|
||||
certificates_section.wait_for_page()
|
||||
return certificates_section
|
||||
|
||||
def select_special_exams(self):
|
||||
"""
|
||||
Selects the timed exam tab and returns the Special Exams Section
|
||||
"""
|
||||
self.q(css='[data-section="special_exams"]').first.click()
|
||||
timed_exam_section = SpecialExamsPage(self.browser)
|
||||
timed_exam_section.wait_for_page()
|
||||
return timed_exam_section
|
||||
|
||||
def select_bulk_email(self):
|
||||
"""
|
||||
Selects the email tab and returns the bulk email section
|
||||
@@ -112,22 +66,6 @@ class InstructorDashboardPage(CoursePage):
|
||||
email_section.wait_for_page()
|
||||
return email_section
|
||||
|
||||
def select_ecommerce_tab(self):
|
||||
"""
|
||||
Selects the E-commerce tab and returns an EcommercePage.
|
||||
"""
|
||||
self.q(css='[data-section="e-commerce"]').first.click()
|
||||
ecommerce_section = EcommercePage(self.browser)
|
||||
ecommerce_section.wait_for_page()
|
||||
return ecommerce_section
|
||||
|
||||
def is_rescore_unsupported_message_visible(self):
|
||||
if (self.q(css='.request-response-error').present):
|
||||
return u'This component cannot be rescored.' in six.text_type(
|
||||
self.q(css='.request-response-error').html
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_asset_path(file_name):
|
||||
"""
|
||||
@@ -151,117 +89,6 @@ class InstructorDashboardPage(CoursePage):
|
||||
return os.sep.join(folders_list_in_path)
|
||||
|
||||
|
||||
class BulkEmailPage(PageObject):
|
||||
"""
|
||||
Bulk email section of the instructor dashboard.
|
||||
This feature is controlled by an admin panel feature flag, which is turned on via database fixture for testing.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='[data-section=send_email].active-section').present
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to the bulk-email context.
|
||||
"""
|
||||
return u'.send-email {}'.format(selector)
|
||||
|
||||
def _select_recipient(self, recipient):
|
||||
"""
|
||||
Selects the specified recipient from the selector. Assumes that recipient is not None.
|
||||
"""
|
||||
recipient_selector_css = "input[name='send_to'][value='{}']".format(recipient)
|
||||
self.q(css=self._bounded_selector(recipient_selector_css))[0].click()
|
||||
|
||||
def send_message(self, recipients):
|
||||
"""
|
||||
Send a test message to the specified recipient.
|
||||
"""
|
||||
send_css = "input[name='send']"
|
||||
test_subject = "Hello"
|
||||
test_body = "This is a test email"
|
||||
|
||||
for recipient in recipients:
|
||||
self._select_recipient(recipient)
|
||||
self.q(css=self._bounded_selector("input[name='subject']")).fill(test_subject)
|
||||
self.q(css=self._bounded_selector("iframe#mce_0_ifr"))[0].click()
|
||||
self.q(css=self._bounded_selector("iframe#mce_0_ifr"))[0].send_keys(test_body)
|
||||
|
||||
with self.handle_alert(confirm=True):
|
||||
self.q(css=self._bounded_selector(send_css)).click()
|
||||
|
||||
def verify_message_queued_successfully(self):
|
||||
"""
|
||||
Verifies that the "you email was queued" message appears.
|
||||
|
||||
Note that this does NOT ensure the message gets sent successfully, that functionality
|
||||
is covered by the bulk_email unit tests.
|
||||
"""
|
||||
confirmation_selector = self._bounded_selector(".msg-confirm")
|
||||
expected_text = u"Your email message was successfully queued for sending."
|
||||
EmptyPromise(
|
||||
lambda: expected_text in self.q(css=confirmation_selector)[0].text,
|
||||
"Message Queued Confirmation"
|
||||
).fulfill()
|
||||
|
||||
|
||||
class MembershipPage(PageObject):
|
||||
"""
|
||||
Membership section of the Instructor dashboard.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='[data-section=membership].active-section').present
|
||||
|
||||
def select_auto_enroll_section(self):
|
||||
"""
|
||||
Returns the MembershipPageAutoEnrollSection page object.
|
||||
"""
|
||||
return MembershipPageAutoEnrollSection(self.browser)
|
||||
|
||||
def batch_beta_tester_addition(self):
|
||||
"""
|
||||
Returns the MembershipPageBetaTesterSection page object.
|
||||
"""
|
||||
return MembershipPageBetaTesterSection(self.browser)
|
||||
|
||||
|
||||
class SpecialExamsPage(PageObject):
|
||||
"""
|
||||
Timed exam section of the Instructor dashboard.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='[data-section=special_exams].active-section').present
|
||||
|
||||
def select_allowance_section(self):
|
||||
"""
|
||||
Expand the allowance section
|
||||
"""
|
||||
allowance_section = SpecialExamsPageAllowanceSection(self.browser)
|
||||
if not self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-0[aria-selected=true]").present:
|
||||
self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-0").click()
|
||||
self.wait_for_element_presence("div.wrap #ui-accordion-proctoring-accordion-header-0[aria-selected=true]",
|
||||
"Allowance Section")
|
||||
allowance_section.wait_for_page()
|
||||
return allowance_section
|
||||
|
||||
def select_exam_attempts_section(self):
|
||||
"""
|
||||
Expand the Student Attempts Section
|
||||
"""
|
||||
exam_attempts_section = SpecialExamsPageAttemptsSection(self.browser)
|
||||
if not self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-1[aria-selected=true]").present:
|
||||
self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-1").click()
|
||||
self.wait_for_element_presence("div.wrap #ui-accordion-proctoring-accordion-header-1[aria-selected=true]",
|
||||
"Attempts Section")
|
||||
exam_attempts_section.wait_for_page()
|
||||
return exam_attempts_section
|
||||
|
||||
|
||||
class CohortManagementSection(PageObject):
|
||||
"""
|
||||
The Cohort Management section of the Instructor dashboard.
|
||||
@@ -690,147 +517,31 @@ class CohortManagementSection(PageObject):
|
||||
self.q(css=self._bounded_selector('.wrapper-cohort-supplemental')).visible)
|
||||
|
||||
|
||||
class DiscussionManagementSection(PageObject):
|
||||
|
||||
class BulkEmailPage(PageObject):
|
||||
"""
|
||||
Bulk email section of the instructor dashboard.
|
||||
This feature is controlled by an admin panel feature flag, which is turned on via database fixture for testing.
|
||||
"""
|
||||
url = None
|
||||
|
||||
discussion_form_selectors = {
|
||||
'course-wide': '.cohort-course-wide-discussions-form',
|
||||
'inline': '.cohort-inline-discussions-form',
|
||||
'scheme': '.division-scheme-container',
|
||||
}
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='[data-section=send_email].active-section').present
|
||||
|
||||
NOT_DIVIDED_SCHEME = "none"
|
||||
COHORT_SCHEME = "cohort"
|
||||
ENROLLMENT_TRACK_SCHEME = "enrollment_track"
|
||||
|
||||
class MembershipPage(PageObject):
|
||||
"""
|
||||
Membership section of the Instructor dashboard.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css=self.discussion_form_selectors['course-wide']).present
|
||||
return self.q(css='[data-section=membership].active-section').present
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
def select_auto_enroll_section(self):
|
||||
"""
|
||||
Return `selector`, but limited to the divided discussion management context.
|
||||
Returns the MembershipPageAutoEnrollSection page object.
|
||||
"""
|
||||
return u'.discussions-management {}'.format(selector)
|
||||
|
||||
def is_save_button_disabled(self, key):
|
||||
"""
|
||||
Returns the status for form's save button, enabled or disabled.
|
||||
"""
|
||||
save_button_css = u'%s %s' % (self.discussion_form_selectors[key], '.action-save')
|
||||
disabled = self.q(css=self._bounded_selector(save_button_css)).attrs('disabled')
|
||||
return disabled[0] == 'true'
|
||||
|
||||
def discussion_topics_visible(self):
|
||||
"""
|
||||
Returns the visibility status of divide discussion controls.
|
||||
"""
|
||||
return (self.q(css=self._bounded_selector('.course-wide-discussions-nav')).visible and
|
||||
self.q(css=self._bounded_selector('.inline-discussions-nav')).visible)
|
||||
|
||||
def divided_discussion_heading_is_visible(self, key):
|
||||
"""
|
||||
Returns the text of discussion topic headings if it exists, otherwise return False.
|
||||
"""
|
||||
form_heading_css = u'%s %s' % (self.discussion_form_selectors[key], '.subsection-title')
|
||||
discussion_heading = self.q(css=self._bounded_selector(form_heading_css))
|
||||
|
||||
if len(discussion_heading) == 0:
|
||||
return False
|
||||
return discussion_heading.first.text[0]
|
||||
|
||||
def select_always_inline_discussion(self):
|
||||
"""
|
||||
Selects the always_divide_inline_discussions radio button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".check-all-inline-discussions")).first.click()
|
||||
|
||||
def inline_discussion_topics_disabled(self):
|
||||
"""
|
||||
Returns the status of inline discussion topics, enabled or disabled.
|
||||
"""
|
||||
inline_topics = self.q(css=self._bounded_selector('.check-discussion-subcategory-inline'))
|
||||
return all(topic.get_attribute('disabled') == 'true' for topic in inline_topics)
|
||||
|
||||
def save_discussion_topics(self, key):
|
||||
"""
|
||||
Saves the discussion topics.
|
||||
"""
|
||||
save_button_css = u'%s %s' % (self.discussion_form_selectors[key], '.action-save')
|
||||
self.q(css=self._bounded_selector(save_button_css)).first.click()
|
||||
|
||||
def always_inline_discussion_selected(self):
|
||||
"""
|
||||
Returns true if always_divide_inline_discussions radio button is selected.
|
||||
"""
|
||||
return len(self.q(css=self._bounded_selector(".check-all-inline-discussions:checked"))) > 0
|
||||
|
||||
def divide_some_inline_discussion_selected(self):
|
||||
"""
|
||||
Returns true if divide_some_inline_discussions radio button is selected.
|
||||
"""
|
||||
return len(self.q(css=self._bounded_selector(".check-cohort-inline-discussions:checked"))) > 0
|
||||
|
||||
def select_divide_some_inline_discussion(self):
|
||||
"""
|
||||
Selects the divide_some_inline_discussions radio button.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".check-cohort-inline-discussions")).first.click()
|
||||
|
||||
def get_divided_topics_count(self, key):
|
||||
"""
|
||||
Returns the count for divided topics.
|
||||
"""
|
||||
divided_topics = self.q(css=self._bounded_selector('.check-discussion-subcategory-%s:checked' % key))
|
||||
return len(divided_topics.results)
|
||||
|
||||
def select_discussion_topic(self, key):
|
||||
"""
|
||||
Selects discussion topic checkbox by clicking on it.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".check-discussion-subcategory-%s" % key)).first.click()
|
||||
|
||||
def get_divide_discussions_message(self, key, msg_type="confirmation"):
|
||||
"""
|
||||
Returns the message related to modifying discussion topics.
|
||||
"""
|
||||
title_css = u"%s .message-%s .message-title" % (self.discussion_form_selectors[key], msg_type)
|
||||
|
||||
EmptyPromise(
|
||||
lambda: self.q(css=self._bounded_selector(title_css)),
|
||||
"Waiting for message to appear"
|
||||
).fulfill()
|
||||
|
||||
message_title = self.q(css=self._bounded_selector(title_css))
|
||||
|
||||
if len(message_title.results) == 0:
|
||||
return ''
|
||||
return message_title.first.text[0]
|
||||
|
||||
def is_category_selected(self):
|
||||
"""
|
||||
Returns the status for category checkboxes.
|
||||
"""
|
||||
return self.q(css=self._bounded_selector('.check-discussion-category:checked')).is_present()
|
||||
|
||||
def get_selected_scheme(self):
|
||||
"""
|
||||
Returns the ID of the selected discussion division scheme
|
||||
("NOT_DIVIDED_SCHEME", "COHORT_SCHEME", or "ENROLLMENT_TRACK_SCHEME)".
|
||||
"""
|
||||
return self.q(css=self._bounded_selector('.division-scheme:checked')).first.attrs('value')[0]
|
||||
|
||||
def select_division_scheme(self, scheme):
|
||||
"""
|
||||
Selects the radio button associated with the specified division scheme.
|
||||
"""
|
||||
self.q(css=self._bounded_selector("input.%s" % scheme)).first.click()
|
||||
|
||||
def division_scheme_visible(self, scheme):
|
||||
"""
|
||||
Returns whether or not the specified scheme is visible as an option.
|
||||
"""
|
||||
return self.q(css=self._bounded_selector("input.%s" % scheme)).visible
|
||||
return MembershipPageAutoEnrollSection(self.browser)
|
||||
|
||||
|
||||
class MembershipPageAutoEnrollSection(PageObject):
|
||||
@@ -940,413 +651,6 @@ class MembershipPageAutoEnrollSection(PageObject):
|
||||
return self.q(css=u"{} h3".format(notification_selector)).text
|
||||
|
||||
|
||||
class MembershipPageBetaTesterSection(PageObject):
|
||||
"""
|
||||
Beta tester section of the Membership tab of the Instructor dashboard.
|
||||
"""
|
||||
url = None
|
||||
|
||||
batch_beta_tester_heading_selector = '#heading-batch-beta-testers'
|
||||
batch_beta_tester_selector = '.batch-beta-testers'
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css=self.batch_beta_tester_heading_selector).present
|
||||
|
||||
def fill_batch_beta_tester_addition_text_box(self, username):
|
||||
"""
|
||||
Fill in the form with the provided username and submit it.
|
||||
"""
|
||||
username_selector = u"{} textarea".format(self.batch_beta_tester_selector)
|
||||
enrollment_button = u"{} .enrollment-button[data-action='add']".format(self.batch_beta_tester_selector)
|
||||
|
||||
# Fill the username after the username selector is visible.
|
||||
self.wait_for_element_visibility(username_selector, 'username field is visible')
|
||||
self.q(css=username_selector).fill(username)
|
||||
|
||||
# Verify enrollment button is present before clicking
|
||||
self.wait_for_element_visibility(enrollment_button, 'Add beta testers button')
|
||||
self.q(css=enrollment_button).click()
|
||||
|
||||
def get_notification_text(self):
|
||||
"""
|
||||
Check notification div is visible and have message.
|
||||
"""
|
||||
notification_selector = u'{} .request-response'.format(self.batch_beta_tester_selector)
|
||||
self.wait_for_element_visibility(notification_selector, 'Notification div is visible')
|
||||
notification_header_text = self.q(css=u"{} h3".format(notification_selector)).text
|
||||
notification_username = self.q(css=u"{} li".format(notification_selector)).text
|
||||
return notification_header_text, notification_username
|
||||
|
||||
|
||||
class SpecialExamsPageAllowanceSection(PageObject):
|
||||
"""
|
||||
Allowance section of the Instructor dashboard's Special Exams tab.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-0[aria-selected=true]").present
|
||||
|
||||
@property
|
||||
def is_add_allowance_button_visible(self):
|
||||
"""
|
||||
Returns True if the Add Allowance button is present.
|
||||
"""
|
||||
return self.q(css="a#add-allowance").present
|
||||
|
||||
@property
|
||||
def is_allowance_record_visible(self):
|
||||
"""
|
||||
Returns True if the Add Allowance button is present.
|
||||
"""
|
||||
return self.q(css="table.allowance-table tr.allowance-items").present
|
||||
|
||||
@property
|
||||
def is_add_allowance_popup_visible(self):
|
||||
"""
|
||||
Returns True if the Add Allowance popup and it's all assets are present.
|
||||
"""
|
||||
return self.q(css="div.modal div.modal-header").present and self._are_all_assets_present()
|
||||
|
||||
def _are_all_assets_present(self):
|
||||
"""
|
||||
Returns True if all the assets present in add allowance popup/form
|
||||
"""
|
||||
return (
|
||||
self.q(css="select#proctored_exam").present and
|
||||
self.q(css="label#exam_type_label").present and
|
||||
self.q(css="input#allowance_value").present and
|
||||
self.q(css="input#user_info").present and
|
||||
self.q(css="input#addNewAllowance").present
|
||||
) and (
|
||||
# This will be present if exam is proctored
|
||||
self.q(css="select#allowance_type").present or
|
||||
# This will be present if exam is timed
|
||||
self.q(css="label#timed_exam_allowance_type").present
|
||||
)
|
||||
|
||||
def click_add_allowance_button(self):
|
||||
"""
|
||||
Click the add allowance button
|
||||
"""
|
||||
self.q(css="a#add-allowance").click()
|
||||
self.wait_for_element_presence("div.modal div.modal-header", "Popup should be visible")
|
||||
|
||||
def submit_allowance_form(self, allowed_minutes, username):
|
||||
"""
|
||||
Fill and submit the allowance
|
||||
"""
|
||||
self.q(css='input#allowance_value').fill(allowed_minutes)
|
||||
self.q(css='input#user_info').fill(username)
|
||||
self.q(css="input#addNewAllowance").click()
|
||||
self.wait_for_element_absence("div.modal div.modal-header", "Popup should be hidden")
|
||||
self.wait_for_ajax()
|
||||
|
||||
|
||||
class SpecialExamsPageAttemptsSection(PageObject):
|
||||
"""
|
||||
Exam Attempts section of the Instructor dashboard's Special Exams tab.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return (self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-1[aria-selected=true]").present and
|
||||
self.q(css="#search_attempt_id").present)
|
||||
|
||||
@property
|
||||
def is_search_text_field_visible(self):
|
||||
"""
|
||||
Returns True if the search field is present
|
||||
"""
|
||||
return self.q(css="#search_attempt_id").present
|
||||
|
||||
@property
|
||||
def is_student_attempt_visible(self):
|
||||
"""
|
||||
Returns True if a row with the Student's attempt is present
|
||||
"""
|
||||
return self.q(css="a.remove-attempt").present
|
||||
|
||||
def remove_student_attempt(self):
|
||||
"""
|
||||
Clicks the "x" to remove the Student's attempt.
|
||||
"""
|
||||
with self.handle_alert(confirm=True):
|
||||
self.q(css=".remove-attempt").first.click()
|
||||
self.wait_for_element_absence(".remove-attempt", "exam attempt")
|
||||
|
||||
|
||||
class DataDownloadPage(PageObject):
|
||||
"""
|
||||
Data Download section of the Instructor dashboard.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='[data-section=data_download].active-section').present
|
||||
|
||||
@property
|
||||
def generate_student_report_button(self):
|
||||
"""
|
||||
Returns the "Download profile information as a CSV" button.
|
||||
"""
|
||||
return self.q(css='input[name=list-profiles-csv]')
|
||||
|
||||
@property
|
||||
def enrolled_student_profile_button(self):
|
||||
"""
|
||||
Returns the "List enrolled students' profile information" button.
|
||||
"""
|
||||
return self.q(css='input[name=list-profiles]')
|
||||
|
||||
@property
|
||||
def enrolled_student_profile_button_present(self):
|
||||
"""
|
||||
Checks for the presence of Enrolled Student Profile Button
|
||||
"""
|
||||
return self.q(css='input[name=list-profiles]').present
|
||||
|
||||
@property
|
||||
def generate_grading_configuration_button(self):
|
||||
"""
|
||||
Returns the "Grading Configuration" button.
|
||||
"""
|
||||
return self.q(css='input[name="dump-gradeconf"]')
|
||||
|
||||
@property
|
||||
def generate_grade_report_button(self):
|
||||
"""
|
||||
Returns the "Generate Grade Report" button.
|
||||
"""
|
||||
return self.q(css='input[name=calculate-grades-csv]')
|
||||
|
||||
@property
|
||||
def generate_problem_report_button(self):
|
||||
"""
|
||||
Returns the "Generate Problem Grade Report" button.
|
||||
"""
|
||||
return self.q(css='input[name=problem-grade-report]')
|
||||
|
||||
@property
|
||||
def report_download_links(self):
|
||||
"""
|
||||
Returns the download links for the current page.
|
||||
"""
|
||||
return self.q(css="#report-downloads-table .file-download-link>a")
|
||||
|
||||
@property
|
||||
def generate_ora2_response_report_button(self):
|
||||
"""
|
||||
Returns the ORA2 response download button for the current page.
|
||||
"""
|
||||
return self.q(css='input[name=export-ora2-data]')
|
||||
|
||||
def wait_for_available_report(self):
|
||||
"""
|
||||
Waits for a downloadable report to be available.
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: len(self.report_download_links) >= 1, 'Waiting for downloadable report'
|
||||
).fulfill()
|
||||
|
||||
def get_available_reports_for_download(self):
|
||||
"""
|
||||
Returns a list of all the available reports for download.
|
||||
"""
|
||||
return self.report_download_links.map(lambda el: el.text)
|
||||
|
||||
@property
|
||||
def student_profile_information(self):
|
||||
"""
|
||||
Returns Student profile information
|
||||
"""
|
||||
return self.q(css='#data-student-profiles-table').text
|
||||
|
||||
@property
|
||||
def grading_config_text(self):
|
||||
"""
|
||||
Returns grading configuration text
|
||||
"""
|
||||
self.wait_for_element_visibility('#data-grade-config-text', 'Grading Configurations are visible')
|
||||
return self.q(css='#data-grade-config-text').text[0]
|
||||
|
||||
|
||||
class StudentAdminPage(PageObject):
|
||||
"""
|
||||
Student admin section of the Instructor dashboard.
|
||||
"""
|
||||
url = None
|
||||
PAGE_SELECTOR = 'section#student_admin'
|
||||
CONTAINER = None
|
||||
|
||||
PROBLEM_INPUT_NAME = None
|
||||
STUDENT_EMAIL_INPUT_NAME = None
|
||||
|
||||
RESET_ATTEMPTS_BUTTON_NAME = None
|
||||
RESCORE_BUTTON_NAME = None
|
||||
RESCORE_IF_HIGHER_BUTTON_NAME = None
|
||||
DELETE_STATE_BUTTON_NAME = None
|
||||
|
||||
BACKGROUND_TASKS_BUTTON_NAME = None
|
||||
TASK_HISTORY_TABLE_NAME = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Confirms student admin section is present
|
||||
"""
|
||||
return self.q(css='[data-section=student_admin].active-section').present
|
||||
|
||||
def _input_with_name(self, input_name):
|
||||
"""
|
||||
Returns the input box with the given name
|
||||
for this object's container.
|
||||
"""
|
||||
return self.q(css=u'{} input[name={}]'.format(self.CONTAINER, input_name))
|
||||
|
||||
@property
|
||||
def problem_location_input(self):
|
||||
"""
|
||||
Returns input box for problem location
|
||||
"""
|
||||
return self._input_with_name(self.PROBLEM_INPUT_NAME)
|
||||
|
||||
def set_problem_location(self, problem_location):
|
||||
"""
|
||||
Returns input box for problem location
|
||||
"""
|
||||
input_box = self.problem_location_input.first.results[0]
|
||||
input_box.send_keys(six.text_type(problem_location))
|
||||
|
||||
@property
|
||||
def student_email_or_username_input(self):
|
||||
"""
|
||||
Returns email address/username input box.
|
||||
"""
|
||||
return self._input_with_name(self.STUDENT_EMAIL_INPUT_NAME)
|
||||
|
||||
def set_student_email_or_username(self, email_or_username):
|
||||
"""
|
||||
Sets given email or username as value of
|
||||
student email/username input box.
|
||||
"""
|
||||
input_box = self.student_email_or_username_input.first.results[0]
|
||||
input_box.send_keys(email_or_username)
|
||||
|
||||
@property
|
||||
def reset_attempts_button(self):
|
||||
"""
|
||||
Returns reset student attempts button.
|
||||
"""
|
||||
return self._input_with_name(self.RESET_ATTEMPTS_BUTTON_NAME)
|
||||
|
||||
@property
|
||||
def rescore_button(self):
|
||||
"""
|
||||
Returns rescore button.
|
||||
"""
|
||||
return self._input_with_name(self.RESCORE_BUTTON_NAME)
|
||||
|
||||
@property
|
||||
def rescore_if_higher_button(self):
|
||||
"""
|
||||
Returns rescore if higher button.
|
||||
"""
|
||||
return self._input_with_name(self.RESCORE_IF_HIGHER_BUTTON_NAME)
|
||||
|
||||
@property
|
||||
def delete_state_button(self):
|
||||
"""
|
||||
Returns delete state button.
|
||||
"""
|
||||
return self._input_with_name(self.DELETE_STATE_BUTTON_NAME)
|
||||
|
||||
@property
|
||||
def task_history_button(self):
|
||||
"""
|
||||
Return Background Tasks History button.
|
||||
"""
|
||||
return self._input_with_name(self.BACKGROUND_TASKS_BUTTON_NAME)
|
||||
|
||||
@property
|
||||
def running_tasks_section(self):
|
||||
"""
|
||||
Returns the "Pending Instructor Tasks" section.
|
||||
"""
|
||||
return self.get_selector('div.running-tasks-container')
|
||||
|
||||
def get_selector(self, css_selector):
|
||||
"""
|
||||
Makes query selector by pre-pending student admin section
|
||||
"""
|
||||
return self.q(css=' '.join([self.PAGE_SELECTOR, css_selector]))
|
||||
|
||||
def wait_for_task_history_table(self):
|
||||
"""
|
||||
Waits until the task history table is visible.
|
||||
"""
|
||||
def check_func():
|
||||
"""
|
||||
Promise Check Function
|
||||
"""
|
||||
query = self.q(css=u"{} .{}".format(self.CONTAINER, self.TASK_HISTORY_TABLE_NAME))
|
||||
return query.visible, query
|
||||
|
||||
return Promise(check_func, "Waiting for student admin task history table to be visible.").fulfill()
|
||||
|
||||
def wait_for_task_completion(self, expected_task_string):
|
||||
"""
|
||||
Waits until the task history table is visible.
|
||||
"""
|
||||
def check_func():
|
||||
"""
|
||||
Promise Check Function
|
||||
"""
|
||||
self.task_history_button.click()
|
||||
table = self.wait_for_task_history_table()
|
||||
return len(table) > 0 and expected_task_string in table.results[0].text
|
||||
|
||||
return EmptyPromise(check_func, "Waiting for student admin task to complete.").fulfill()
|
||||
|
||||
def click_grade_book_link(self):
|
||||
self.wait_for_element_presence('.gradebook-link', "Grade book link is not present.")
|
||||
self.q(css='.gradebook-link').first.click()
|
||||
|
||||
|
||||
class GradeBookPage(StudentAdminPage):
|
||||
"""
|
||||
Grade Book section of the Instructor dashboard.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Confirms grade book section is present
|
||||
"""
|
||||
return self.q(css='.grade-table').present
|
||||
|
||||
def get_value_in_the_grade_book(self, title, index):
|
||||
"""Find the element with given CSS selector, index and return its text"""
|
||||
return self.q(css='.grade-table td[title^="{}"]'.format(title)).text[index]
|
||||
|
||||
|
||||
class StudentSpecificAdmin(StudentAdminPage):
|
||||
"""
|
||||
Student specific section of the Student Admin page.
|
||||
"""
|
||||
CONTAINER = ".student-grade-container"
|
||||
|
||||
PROBLEM_INPUT_NAME = "problem-select-single"
|
||||
STUDENT_EMAIL_INPUT_NAME = "student-select-grade"
|
||||
|
||||
RESET_ATTEMPTS_BUTTON_NAME = "reset-attempts-single"
|
||||
RESCORE_BUTTON_NAME = "rescore-problem-single"
|
||||
RESCORE_IF_HIGHER_BUTTON_NAME = "rescore-problem-if-higher-single"
|
||||
DELETE_STATE_BUTTON_NAME = "delete-state-single"
|
||||
|
||||
BACKGROUND_TASKS_BUTTON_NAME = "task-history-single"
|
||||
TASK_HISTORY_TABLE_NAME = "task-history-single-table"
|
||||
|
||||
|
||||
class CertificatesPage(PageObject):
|
||||
"""
|
||||
Certificates section of the Instructor dashboard.
|
||||
@@ -1354,195 +658,5 @@ class CertificatesPage(PageObject):
|
||||
url = None
|
||||
PAGE_SELECTOR = 'section#certificates'
|
||||
|
||||
def wait_for_certificate_exceptions_section(self):
|
||||
"""
|
||||
Wait for Certificate Exceptions to be rendered on page
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'div.certificate-exception-container',
|
||||
'Certificate Exception Section is visible'
|
||||
)
|
||||
self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible')
|
||||
|
||||
def wait_for_certificate_invalidations_section(self): # pylint: disable=invalid-name
|
||||
"""
|
||||
Wait for certificate invalidations section to be rendered on page
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'div.certificate-invalidation-container',
|
||||
'Certificate invalidations section is visible.'
|
||||
)
|
||||
self.wait_for_element_visibility('#invalidate-certificate', 'Invalidate Certificate button is visible')
|
||||
|
||||
def refresh(self):
|
||||
"""
|
||||
Refresh Certificates Page and wait for the page to load completely.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
self.wait_for_page()
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='[data-section=certificates].active-section').present
|
||||
|
||||
def get_selector(self, css_selector):
|
||||
"""
|
||||
Makes query selector by pre-pending certificates section
|
||||
"""
|
||||
return self.q(css=' '.join([self.PAGE_SELECTOR, css_selector]))
|
||||
|
||||
def add_certificate_exception(self, student, free_text_note):
|
||||
"""
|
||||
Add Certificate Exception for 'student'.
|
||||
"""
|
||||
self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible')
|
||||
|
||||
self.get_selector('#certificate-exception').fill(student)
|
||||
self.get_selector('#notes').fill(free_text_note)
|
||||
self.get_selector('#add-exception').click()
|
||||
|
||||
self.wait_for_ajax()
|
||||
self.wait_for(
|
||||
lambda: student in self.get_selector('div.white-listed-students table tr:last-child td').text,
|
||||
description='Certificate Exception added to list'
|
||||
)
|
||||
|
||||
def remove_first_certificate_exception(self):
|
||||
"""
|
||||
Remove Certificate Exception from the white list.
|
||||
"""
|
||||
self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible')
|
||||
self.get_selector('div.white-listed-students table tr td .delete-exception').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def click_generate_certificate_exceptions_button(self): # pylint: disable=invalid-name
|
||||
"""
|
||||
Click 'Generate Exception Certificates' button in 'Certificates Exceptions' section
|
||||
"""
|
||||
self.get_selector('#generate-exception-certificates').click()
|
||||
|
||||
def fill_user_name_field(self, student):
|
||||
"""
|
||||
Fill username/email field with given text
|
||||
"""
|
||||
self.get_selector('#certificate-exception').fill(student)
|
||||
|
||||
def click_add_exception_button(self):
|
||||
"""
|
||||
Click 'Add Exception' button in 'Certificates Exceptions' section
|
||||
"""
|
||||
self.get_selector('#add-exception').click()
|
||||
|
||||
def add_certificate_invalidation(self, student, notes):
|
||||
"""
|
||||
Add certificate invalidation for 'student'.
|
||||
"""
|
||||
self.wait_for_element_visibility('#invalidate-certificate', 'Invalidate Certificate button is visible')
|
||||
|
||||
self.get_selector('#certificate-invalidation-user').fill(student)
|
||||
self.get_selector('#certificate-invalidation-notes').fill(notes)
|
||||
self.get_selector('#invalidate-certificate').click()
|
||||
|
||||
self.wait_for_ajax()
|
||||
self.wait_for(
|
||||
lambda: student in self.get_selector('div.invalidation-history table tr:last-child td').text,
|
||||
description='Certificate invalidation added to list.'
|
||||
)
|
||||
|
||||
def remove_first_certificate_invalidation(self):
|
||||
"""
|
||||
Remove certificate invalidation from the invalidation list.
|
||||
"""
|
||||
self.wait_for_element_visibility('#invalidate-certificate', 'Invalidate Certificate button is visible')
|
||||
self.get_selector('div.invalidation-history table tr td .re-validate-certificate').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def fill_certificate_invalidation_user_name_field(self, student): # pylint: disable=invalid-name
|
||||
"""
|
||||
Fill username/email field with given text
|
||||
"""
|
||||
self.get_selector('#certificate-invalidation-user').fill(student)
|
||||
|
||||
def click_invalidate_certificate_button(self):
|
||||
"""
|
||||
Click 'Invalidate Certificate' button in 'certificates invalidations' section
|
||||
"""
|
||||
self.get_selector('#invalidate-certificate').click()
|
||||
|
||||
@property
|
||||
def generate_certificates_button(self):
|
||||
"""
|
||||
Returns the "Generate Certificates" button.
|
||||
"""
|
||||
return self.get_selector('#btn-start-generating-certificates')
|
||||
|
||||
@property
|
||||
def generate_certificates_disabled_button(self):
|
||||
"""
|
||||
Returns the disabled state of button
|
||||
"""
|
||||
return self.get_selector('#disabled-btn-start-generating-certificates')
|
||||
|
||||
@property
|
||||
def certificate_generation_status(self):
|
||||
"""
|
||||
Returns certificate generation status message container.
|
||||
"""
|
||||
return self.get_selector('div.certificate-generation-status')
|
||||
|
||||
@property
|
||||
def pending_tasks_section(self):
|
||||
"""
|
||||
Returns the "Pending Instructor Tasks" section.
|
||||
"""
|
||||
return self.get_selector('div.running-tasks-container')
|
||||
|
||||
@property
|
||||
def certificate_exceptions_section(self):
|
||||
"""
|
||||
Returns the "Certificate Exceptions" section.
|
||||
"""
|
||||
return self.get_selector('div.certificate-exception-container')
|
||||
|
||||
@property
|
||||
def last_certificate_exception(self):
|
||||
"""
|
||||
Returns the Last Certificate Exception in Certificate Exceptions list in "Certificate Exceptions" section.
|
||||
"""
|
||||
return self.get_selector('div.white-listed-students table tr:last-child td')
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
"""
|
||||
Returns the Message (error/success) in "Certificate Exceptions" section.
|
||||
"""
|
||||
return self.get_selector('.certificate-exception-container div.message')
|
||||
|
||||
@property
|
||||
def last_certificate_invalidation(self):
|
||||
"""
|
||||
Returns last certificate invalidation from "Certificate Invalidations" section.
|
||||
"""
|
||||
return self.get_selector('div.certificate-invalidation-container table tr:last-child td')
|
||||
|
||||
@property
|
||||
def certificate_invalidation_message(self):
|
||||
"""
|
||||
Returns the message (error/success) in "Certificate Invalidation" section.
|
||||
"""
|
||||
return self.get_selector('.certificate-invalidation-container div.message')
|
||||
|
||||
|
||||
class EcommercePage(PageObject):
|
||||
"""
|
||||
E-commerce section of the Instructor dashboard.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='[data-section="e-commerce"].active-section').present
|
||||
|
||||
def get_sections_header_values(self):
|
||||
"""
|
||||
Returns a list of the headings text under div.
|
||||
"""
|
||||
return self.q(css="div.wrap h3").text
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""
|
||||
Library Content XBlock Wrapper
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
|
||||
class LibraryContentXBlockWrapper(PageObject):
|
||||
"""
|
||||
A PageObject representing a wrapper around a LibraryContent block seen in the LMS
|
||||
"""
|
||||
url = None
|
||||
BODY_SELECTOR = '.xblock-student_view div'
|
||||
|
||||
def __init__(self, browser, locator):
|
||||
super(LibraryContentXBlockWrapper, self).__init__(browser)
|
||||
self.locator = locator
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Checks if page is opened
|
||||
"""
|
||||
return self.q(css='{}[data-id="{}"]'.format(self.BODY_SELECTOR, self.locator)).present
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular block's context
|
||||
"""
|
||||
return u'{}[data-id="{}"] {}'.format(
|
||||
self.BODY_SELECTOR,
|
||||
self.locator,
|
||||
selector
|
||||
)
|
||||
|
||||
@property
|
||||
def children_contents(self):
|
||||
"""
|
||||
Gets contents of all child XBlocks as list of strings
|
||||
"""
|
||||
child_blocks = self.q(css=self._bounded_selector("div[data-id]"))
|
||||
return frozenset(child.text for child in child_blocks)
|
||||
|
||||
@property
|
||||
def children_headers(self):
|
||||
"""
|
||||
Gets headers of all child XBlocks as list of strings
|
||||
"""
|
||||
child_blocks_headers = self.q(css=self._bounded_selector("div[data-id] .problem-header"))
|
||||
return frozenset(child.text for child in child_blocks_headers)
|
||||
@@ -1,54 +0,0 @@
|
||||
"""
|
||||
Login page for the LMS.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise
|
||||
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
from common.test.acceptance.pages.lms.dashboard import DashboardPage
|
||||
|
||||
|
||||
class LoginPage(PageObject):
|
||||
"""
|
||||
Login page for the LMS.
|
||||
"""
|
||||
|
||||
url = BASE_URL + "/login"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return any([
|
||||
'log in' in title.lower()
|
||||
for title in self.q(css='span.title-super').text
|
||||
])
|
||||
|
||||
def login(self, email, password):
|
||||
"""
|
||||
Attempt to log in using `email` and `password`.
|
||||
"""
|
||||
self.provide_info(email, password)
|
||||
self.submit()
|
||||
|
||||
def provide_info(self, email, password):
|
||||
"""
|
||||
Fill in login info.
|
||||
`email` and `password` are the user's credentials.
|
||||
"""
|
||||
EmptyPromise(self.q(css='input#email').is_present, "Click ready").fulfill()
|
||||
EmptyPromise(self.q(css='input#password').is_present, "Click ready").fulfill()
|
||||
|
||||
self.q(css='input#email').fill(email)
|
||||
self.q(css='input#password').fill(password)
|
||||
self.wait_for_ajax()
|
||||
|
||||
def submit(self):
|
||||
"""
|
||||
Submit registration info to create an account.
|
||||
"""
|
||||
self.q(css='button#submit').first.click()
|
||||
|
||||
# The next page is the dashboard; make sure it loads
|
||||
dashboard = DashboardPage(self.browser)
|
||||
dashboard.wait_for_page()
|
||||
return dashboard
|
||||
@@ -1,363 +0,0 @@
|
||||
"""Login and Registration pages """
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject, unguarded
|
||||
from bok_choy.promise import EmptyPromise, Promise
|
||||
from six.moves.urllib.parse import urlencode
|
||||
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
from common.test.acceptance.pages.lms.dashboard import DashboardPage
|
||||
|
||||
|
||||
class RegisterPage(PageObject):
|
||||
"""
|
||||
Registration page (create a new account)
|
||||
"""
|
||||
|
||||
def __init__(self, browser, course_id):
|
||||
"""
|
||||
Course ID is currently of the form "edx/999/2013_Spring"
|
||||
but this format could change.
|
||||
"""
|
||||
super(RegisterPage, self).__init__(browser)
|
||||
self._course_id = course_id
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
URL for the registration page of a course.
|
||||
"""
|
||||
return "{base}/register?course_id={course_id}&enrollment_action={action}".format(
|
||||
base=BASE_URL,
|
||||
course_id=self._course_id,
|
||||
action="enroll",
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return any([
|
||||
'register' in title.lower()
|
||||
for title in self.q(css='span.title-sub').text
|
||||
])
|
||||
|
||||
def provide_info(self, email, password, username, full_name):
|
||||
"""
|
||||
Fill in registration info.
|
||||
`email`, `password`, `username`, and `full_name` are the user's credentials.
|
||||
"""
|
||||
self.wait_for_element_visibility('input#email', 'Email field is shown')
|
||||
self.q(css='input#email').fill(email)
|
||||
self.q(css='input#password').fill(password)
|
||||
self.q(css='input#username').fill(username)
|
||||
self.q(css='input#name').fill(full_name)
|
||||
self.q(css='input#tos-yes').first.click()
|
||||
self.q(css='input#honorcode-yes').first.click()
|
||||
self.q(css="#country option[value='US']").first.click()
|
||||
|
||||
def submit(self):
|
||||
"""
|
||||
Submit registration info to create an account.
|
||||
"""
|
||||
self.q(css='button#submit').first.click()
|
||||
|
||||
# The next page is the dashboard; make sure it loads
|
||||
dashboard = DashboardPage(self.browser)
|
||||
dashboard.wait_for_page()
|
||||
return dashboard
|
||||
|
||||
|
||||
class ResetPasswordPage(PageObject):
|
||||
"""Initialize the page.
|
||||
|
||||
Arguments:
|
||||
browser (Browser): The browser instance.
|
||||
"""
|
||||
url = BASE_URL + "/login#forgot-password-modal"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return (
|
||||
self.q(css="#login-anchor").is_present() and
|
||||
self.q(css="#password-reset-anchor").is_present()
|
||||
)
|
||||
|
||||
def is_form_visible(self):
|
||||
return (
|
||||
not self.q(css="#login-anchor").visible and
|
||||
self.q(css="#password-reset-form").visible
|
||||
)
|
||||
|
||||
def fill_password_reset_form(self, email):
|
||||
"""
|
||||
Fill in the form and submit it
|
||||
"""
|
||||
self.wait_for_element_visibility('#password-reset-email', 'Reset Email field is shown')
|
||||
self.q(css="#password-reset-email").fill(email)
|
||||
self.q(css="button.js-reset").click()
|
||||
|
||||
def is_success_visible(self, selector):
|
||||
"""
|
||||
Check element is visible
|
||||
"""
|
||||
self.wait_for_element_visibility(selector, 'Success div is shown')
|
||||
|
||||
def get_success_message(self):
|
||||
"""
|
||||
Return a success message displayed to the user
|
||||
"""
|
||||
return self.q(css=".submission-success h4").text
|
||||
|
||||
|
||||
class CombinedLoginAndRegisterPage(PageObject):
|
||||
"""Interact with combined login and registration page.
|
||||
|
||||
When enabled, the new page is available from either
|
||||
`/login` or `/register`; the new page is also served at
|
||||
`/account/login/` or `/account/register/`, where it was
|
||||
available for a time during an A/B test.
|
||||
|
||||
Users can reach this page while attempting to enroll
|
||||
in a course, in which case users will be auto-enrolled
|
||||
when they successfully authenticate (unless the course
|
||||
has been paywalled).
|
||||
|
||||
"""
|
||||
def __init__(self, browser, start_page="register", course_id=None):
|
||||
"""Initialize the page.
|
||||
|
||||
Arguments:
|
||||
browser (Browser): The browser instance.
|
||||
|
||||
Keyword Args:
|
||||
start_page (str): Whether to start on the login or register page.
|
||||
course_id (unicode): If provided, load the page as if the user
|
||||
is trying to enroll in a course.
|
||||
|
||||
"""
|
||||
super(CombinedLoginAndRegisterPage, self).__init__(browser)
|
||||
self._course_id = course_id
|
||||
|
||||
if start_page not in ["register", "login"]:
|
||||
raise ValueError("Start page must be either 'register' or 'login'")
|
||||
self._start_page = start_page
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""Return the URL for the combined login/registration page. """
|
||||
url = "{base}/{login_or_register}".format(
|
||||
base=BASE_URL,
|
||||
login_or_register=self._start_page
|
||||
)
|
||||
|
||||
# These are the parameters that would be included if the user
|
||||
# were trying to enroll in a course.
|
||||
if self._course_id is not None:
|
||||
url += "?{params}".format(
|
||||
params=urlencode({
|
||||
"course_id": self._course_id,
|
||||
"enrollment_action": "enroll"
|
||||
})
|
||||
)
|
||||
|
||||
return url
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check whether the combined login/registration page has loaded. """
|
||||
return (
|
||||
self.q(css="#login-anchor").is_present() and
|
||||
self.q(css="#register-anchor").is_present() and
|
||||
self.current_form is not None
|
||||
)
|
||||
|
||||
def toggle_form(self):
|
||||
"""Toggle between the login and registration forms. """
|
||||
old_form = self.current_form
|
||||
|
||||
# Toggle the form
|
||||
if old_form == "login":
|
||||
self.q(css=".form-toggle[data-type='register']").click()
|
||||
else:
|
||||
self.q(css=".form-toggle[data-type='login']").click()
|
||||
|
||||
# Wait for the form to change before returning
|
||||
EmptyPromise(
|
||||
lambda: self.current_form != old_form,
|
||||
"Finish toggling to the other form"
|
||||
).fulfill()
|
||||
|
||||
def register(
|
||||
self, email="", password="", username="", full_name="", country="", favorite_movie=""
|
||||
):
|
||||
"""Fills in and submits the registration form.
|
||||
|
||||
Requires that the "register" form is visible.
|
||||
This does NOT wait for the next page to load,
|
||||
so the caller should wait for the next page
|
||||
(or errors if that's the expected behavior.)
|
||||
|
||||
Keyword Arguments:
|
||||
email (unicode): The user's email address.
|
||||
password (unicode): The user's password.
|
||||
username (unicode): The user's username.
|
||||
full_name (unicode): The user's full name.
|
||||
country (unicode): Two-character country code.
|
||||
|
||||
"""
|
||||
# Fill in the form
|
||||
self.wait_for_element_visibility('#toggle_optional_fields', 'Support education research field is shown')
|
||||
if email:
|
||||
self.q(css="#register-email").fill(email)
|
||||
if full_name:
|
||||
self.q(css="#register-name").fill(full_name)
|
||||
if username:
|
||||
self.q(css="#register-username").fill(username)
|
||||
if password:
|
||||
self.q(css="#register-password").fill(password)
|
||||
if country:
|
||||
self.q(css="#register-country").results[0].send_keys(country)
|
||||
if favorite_movie:
|
||||
self.q(css="#register-favorite_movie").fill(favorite_movie)
|
||||
|
||||
# Submit it
|
||||
self.q(css=".register-button").click()
|
||||
|
||||
def login(self, email="", password=""):
|
||||
"""Fills in and submits the login form.
|
||||
|
||||
Requires that the "login" form is visible.
|
||||
This does NOT wait for the next page to load,
|
||||
so the caller should wait for the next page
|
||||
(or errors if that's the expected behavior).
|
||||
|
||||
Keyword Arguments:
|
||||
email (unicode): The user's email address.
|
||||
password (unicode): The user's password.
|
||||
|
||||
"""
|
||||
# Fill in the form
|
||||
self.wait_for_element_visibility('#login-email', 'Email field is shown')
|
||||
self.q(css="#login-email").fill(email)
|
||||
self.q(css="#login-password").fill(password)
|
||||
|
||||
# Submit it
|
||||
self.q(css=".login-button").click()
|
||||
|
||||
def click_third_party_dummy_provider(self):
|
||||
"""Clicks on the Dummy third party provider login button.
|
||||
|
||||
Requires that the "login" form is visible.
|
||||
This does NOT wait for the ensuing page[s] to load.
|
||||
Only the "Dummy" provider is used for bok choy because it is the only
|
||||
one that doesn't send traffic to external servers.
|
||||
"""
|
||||
self.q(css="button.{}-oa2-dummy".format(self.current_form)).click()
|
||||
|
||||
def password_reset(self, email):
|
||||
"""Navigates to, fills in, and submits the password reset form.
|
||||
|
||||
Requires that the "login" form is visible.
|
||||
|
||||
Keyword Arguments:
|
||||
email (unicode): The user's email address.
|
||||
|
||||
"""
|
||||
login_form = self.current_form
|
||||
|
||||
# Click the password reset link on the login page
|
||||
self.q(css=".forgot-password").click()
|
||||
|
||||
# Wait for the password reset form to load
|
||||
EmptyPromise(
|
||||
lambda: self.current_form != login_form,
|
||||
"Finish toggling to the password reset form"
|
||||
).fulfill()
|
||||
|
||||
# Fill in the form
|
||||
self.wait_for_element_visibility('#password-reset-email', 'Email field is shown')
|
||||
self.q(css="#password-reset-email").fill(email)
|
||||
|
||||
# Submit it
|
||||
self.q(css="button.js-reset").click()
|
||||
|
||||
return CombinedLoginAndRegisterPage(self.browser).wait_for_page()
|
||||
|
||||
@property
|
||||
@unguarded
|
||||
def current_form(self):
|
||||
"""Return the form that is currently visible to the user.
|
||||
|
||||
Returns:
|
||||
Either "register", "login", or "password-reset" if a valid
|
||||
form is loaded.
|
||||
|
||||
If we can't find any of these forms on the page, return None.
|
||||
|
||||
"""
|
||||
if self.q(css=".register-button").visible:
|
||||
return "register"
|
||||
elif self.q(css=".login-button").visible:
|
||||
return "login"
|
||||
elif self.q(css=".js-reset").visible:
|
||||
return "password-reset"
|
||||
elif self.q(css=".proceed-button").visible:
|
||||
return "hinted-login"
|
||||
|
||||
@property
|
||||
def email_value(self):
|
||||
""" Current value of the email form field """
|
||||
return self.q(css="#register-email").attrs('value')[0]
|
||||
|
||||
@property
|
||||
def full_name_value(self):
|
||||
""" Current value of the full_name form field """
|
||||
return self.q(css="#register-name").attrs('value')[0]
|
||||
|
||||
@property
|
||||
def username_value(self):
|
||||
""" Current value of the username form field """
|
||||
return self.q(css="#register-username").attrs('value')[0]
|
||||
|
||||
@property
|
||||
def errors(self):
|
||||
"""Return a list of errors displayed to the user. """
|
||||
return self.q(css=".submission-error li").text
|
||||
|
||||
def wait_for_errors(self):
|
||||
"""Wait for errors to be visible, then return them. """
|
||||
def _check_func():
|
||||
"""Return success status and any errors that occurred."""
|
||||
errors = self.errors
|
||||
if not errors:
|
||||
self.q(css=".register-button").click()
|
||||
return (bool(errors), errors)
|
||||
return Promise(_check_func, "Errors are visible").fulfill()
|
||||
|
||||
@property
|
||||
def success(self):
|
||||
"""Return a success message displayed to the user."""
|
||||
if self.q(css=".submission-success").visible:
|
||||
return self.q(css=".submission-success h4").text
|
||||
|
||||
def wait_for_success(self):
|
||||
"""Wait for a success message to be visible, then return it."""
|
||||
def _check_func():
|
||||
"""Return success status and any errors that occurred."""
|
||||
success = self.success
|
||||
return (bool(success), success)
|
||||
return Promise(_check_func, "Success message is visible").fulfill()
|
||||
|
||||
@unguarded # Because we go from this page -> temporary page -> this page again when testing the Dummy provider
|
||||
def wait_for_auth_status_message(self):
|
||||
"""Wait for a status message to be visible following third_party registration, then return it."""
|
||||
def _check_func():
|
||||
"""Return third party auth status notice message."""
|
||||
selector = '.js-auth-warning div'
|
||||
msg_element = self.q(css=selector)
|
||||
if msg_element.visible:
|
||||
return (True, msg_element.text[0])
|
||||
return (False, None)
|
||||
return Promise(_check_func, "Result of third party auth is visible").fulfill()
|
||||
|
||||
@property
|
||||
def hinted_login_prompt(self):
|
||||
"""Get the message displayed to the user on the hinted-login form"""
|
||||
if self.q(css=".wrapper-other-login .instructions").visible:
|
||||
return self.q(css=".wrapper-other-login .instructions").text[0]
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Payment and verification pages"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import Promise
|
||||
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
from common.test.acceptance.pages.lms.dashboard import DashboardPage
|
||||
|
||||
|
||||
class PaymentAndVerificationFlow(PageObject):
|
||||
"""Interact with the split payment and verification flow.
|
||||
|
||||
The flow can be accessed at the following URLs:
|
||||
`/verify_student/start-flow/{course}/`
|
||||
`/verify_student/upgrade/{course}/`
|
||||
`/verify_student/verify-now/{course}/`
|
||||
`/verify_student/verify-later/{course}/`
|
||||
`/verify_student/payment-confirmation/{course}/`
|
||||
|
||||
Users can reach the flow when attempting to enroll in a course's verified
|
||||
mode, either directly from the track selection page, or by upgrading from
|
||||
the honor mode. Users can also reach the flow when attempting to complete
|
||||
a deferred verification, or when attempting to view a receipt corresponding
|
||||
to an earlier payment.
|
||||
"""
|
||||
def __init__(self, browser, course_id, entry_point='start-flow'):
|
||||
"""Initialize the page.
|
||||
|
||||
Arguments:
|
||||
browser (Browser): The browser instance.
|
||||
course_id (unicode): The course in which the user is enrolling.
|
||||
|
||||
Keyword Arguments:
|
||||
entry_point (str): Where to begin the flow; must be one of 'start-flow',
|
||||
'upgrade', 'verify-now', verify-later', or 'payment-confirmation'.
|
||||
|
||||
Raises:
|
||||
ValueError
|
||||
"""
|
||||
super(PaymentAndVerificationFlow, self).__init__(browser)
|
||||
self._course_id = course_id
|
||||
|
||||
if entry_point not in ['start-flow', 'upgrade', 'verify-now', 'verify-later', 'payment-confirmation']:
|
||||
raise ValueError(
|
||||
"Entry point must be either 'start-flow', 'upgrade', 'verify-now', 'verify-later', or 'payment-confirmation'."
|
||||
)
|
||||
self._entry_point = entry_point
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""Return the URL corresponding to the initial position in the flow."""
|
||||
url = "{base}/verify_student/{entry_point}/{course}/".format(
|
||||
base=BASE_URL,
|
||||
entry_point=self._entry_point,
|
||||
course=self._course_id
|
||||
)
|
||||
|
||||
return url
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check if a step in the payment and verification flow has loaded."""
|
||||
return (
|
||||
self.q(css="div .make-payment-step").is_present() or
|
||||
self.q(css="div .payment-confirmation-step").is_present() or
|
||||
self.q(css="div .face-photo-step").is_present() or
|
||||
self.q(css="div .id-photo-step").is_present() or
|
||||
self.q(css="div .review-photos-step").is_present() or
|
||||
self.q(css="div .enrollment-confirmation-step").is_present()
|
||||
)
|
||||
|
||||
def indicate_contribution(self):
|
||||
"""Interact with the radio buttons appearing on the first page of the upgrade flow."""
|
||||
self.q(css=".contribution-option > input").first.click()
|
||||
|
||||
def immediate_verification(self):
|
||||
"""Interact with the immediate verification button."""
|
||||
self.q(css="#verify_now_button").click()
|
||||
|
||||
PaymentAndVerificationFlow(self.browser, self._course_id, entry_point='verify-now').wait_for_page()
|
||||
|
||||
def defer_verification(self):
|
||||
"""Interact with the link allowing the user to defer their verification."""
|
||||
self.q(css="#verify_later_button").click()
|
||||
|
||||
DashboardPage(self.browser).wait_for_page()
|
||||
|
||||
def webcam_capture(self):
|
||||
"""Interact with a webcam capture button."""
|
||||
self.q(css="#webcam_capture_button").click()
|
||||
|
||||
def _check_func():
|
||||
next_step_button_classes = self.q(css="#next_step_button").attrs('class')
|
||||
next_step_button_enabled = 'is-disabled' not in next_step_button_classes
|
||||
return (next_step_button_enabled, next_step_button_classes)
|
||||
|
||||
# Check that the #next_step_button is enabled before returning control to the caller
|
||||
Promise(_check_func, "The 'Next Step' button is enabled.").fulfill()
|
||||
|
||||
def next_verification_step(self, next_page_object):
|
||||
"""Interact with the 'Next' step button found in the verification flow."""
|
||||
self.q(css="#next_step_button").click()
|
||||
|
||||
next_page_object.wait_for_page()
|
||||
|
||||
def go_to_dashboard(self):
|
||||
"""Interact with the link to the dashboard appearing on the enrollment confirmation page."""
|
||||
if self.q(css="div .enrollment-confirmation-step").is_present():
|
||||
self.q(css=".action-primary").click()
|
||||
else:
|
||||
raise Exception("The dashboard can only be accessed from the enrollment confirmation.")
|
||||
|
||||
DashboardPage(self.browser).wait_for_page()
|
||||
@@ -4,8 +4,6 @@ Problem Page.
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from selenium.webdriver import ActionChains
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css
|
||||
|
||||
@@ -36,387 +34,11 @@ class ProblemPage(PageObject):
|
||||
self.wait_for_element_visibility(self.CSS_PROBLEM_HEADER, 'wait for problem header')
|
||||
return self.q(css='.problem-header').text[0]
|
||||
|
||||
@property
|
||||
def problem_text(self):
|
||||
"""
|
||||
Return the text of the question of the problem.
|
||||
"""
|
||||
return self.q(css="div.problem p").text
|
||||
|
||||
@property
|
||||
def problem_input_content(self):
|
||||
"""
|
||||
Return the text of the question of the problem.
|
||||
"""
|
||||
return self.q(css="div.wrapper-problem-response").text[0]
|
||||
|
||||
@property
|
||||
def problem_content(self):
|
||||
"""
|
||||
Return the content of the problem
|
||||
"""
|
||||
return self.q(css="div.problems-wrapper").text[0]
|
||||
|
||||
@property
|
||||
def problem_meta(self):
|
||||
"""
|
||||
Return the problem meta text
|
||||
"""
|
||||
return self.q(css=".problems-wrapper .problem-progress").text[0]
|
||||
|
||||
@property
|
||||
def message_text(self):
|
||||
"""
|
||||
Return the "message" text of the question of the problem.
|
||||
"""
|
||||
return self.q(css="div.problem span.message").text[0]
|
||||
|
||||
@property
|
||||
def extract_hint_text_from_html(self):
|
||||
"""
|
||||
Return the "hint" text of the problem from html
|
||||
"""
|
||||
hints_html = self.q(css="div.problem .notification-hint .notification-message li").html
|
||||
return [hint_html.split(' <span', 1)[0] for hint_html in hints_html]
|
||||
|
||||
@property
|
||||
def hint_text(self):
|
||||
"""
|
||||
Return the "hint" text of the problem from its div.
|
||||
"""
|
||||
return self.q(css="div.problem .notification-hint .notification-message").text[0]
|
||||
|
||||
def verify_mathjax_rendered_in_problem(self):
|
||||
"""
|
||||
Check that MathJax have been rendered in problem hint
|
||||
"""
|
||||
def mathjax_present():
|
||||
""" Returns True if MathJax css is present in the problem body """
|
||||
mathjax_container = self.q(css="div.problem p .MathJax")
|
||||
return mathjax_container.visible and mathjax_container.present
|
||||
|
||||
self.wait_for(
|
||||
mathjax_present,
|
||||
description="MathJax rendered in problem body"
|
||||
)
|
||||
|
||||
def verify_mathjax_rendered_in_preview(self):
|
||||
"""
|
||||
Check that MathJax has been rendered in formula problem preview
|
||||
"""
|
||||
|
||||
def mathjax_present():
|
||||
""" Returns True if MathJax css is present inside the preview """
|
||||
mathjax_container = self.q(css="div.problem div .MathJax")
|
||||
return mathjax_container.visible and mathjax_container.present
|
||||
|
||||
self.wait_for(
|
||||
mathjax_present,
|
||||
description="MathJax rendered in problem preview"
|
||||
)
|
||||
|
||||
def verify_mathjax_rendered_in_hint(self):
|
||||
"""
|
||||
Check that MathJax have been rendered in problem hint
|
||||
"""
|
||||
def mathjax_present():
|
||||
""" Returns True if MathJax css is present in the problem body """
|
||||
mathjax_container = self.q(css="div.problem div.problem-hint .MathJax")
|
||||
return mathjax_container.visible and mathjax_container.present
|
||||
|
||||
self.wait_for(
|
||||
mathjax_present,
|
||||
description="MathJax rendered in hint"
|
||||
)
|
||||
|
||||
def fill_answer(self, text, input_num=None):
|
||||
"""
|
||||
Fill in the answer to the problem.
|
||||
|
||||
args:
|
||||
text: String to fill the input with.
|
||||
|
||||
kwargs:
|
||||
input_num: If provided, fills only the input_numth field. Else, all
|
||||
input fields will be filled.
|
||||
"""
|
||||
fields = self.q(css='div.problem div.capa_inputtype.textline input')
|
||||
fields = fields.nth(input_num) if input_num is not None else fields
|
||||
fields.fill(text)
|
||||
|
||||
def fill_answer_numerical(self, text):
|
||||
"""
|
||||
Fill in the answer to a numerical problem.
|
||||
"""
|
||||
self.q(css='div.problem div.inputtype input').fill(text)
|
||||
self.wait_for_element_invisibility('.loading', 'wait for loading icon to disappear')
|
||||
self.wait_for_ajax()
|
||||
|
||||
@property
|
||||
def get_numerical_input_value(self):
|
||||
"""
|
||||
Get the numerical problem input contents
|
||||
"""
|
||||
return self.q(css='div.problem div.inputtype input').text[0]
|
||||
|
||||
def click_submit(self):
|
||||
"""
|
||||
Click the Submit button.
|
||||
"""
|
||||
click_css(self, '.problem .submit', require_notification=False)
|
||||
|
||||
def click_save(self):
|
||||
"""
|
||||
Click the Save button.
|
||||
"""
|
||||
click_css(self, '.problem .save', require_notification=False)
|
||||
|
||||
def click_reset(self):
|
||||
"""
|
||||
Click the Reset button.
|
||||
"""
|
||||
click_css(self, '.problem .reset', require_notification=False)
|
||||
self.wait_for_ajax()
|
||||
|
||||
def click_show(self):
|
||||
"""
|
||||
Click the Show Answer button.
|
||||
"""
|
||||
css = '.problem .show'
|
||||
# First make sure that the button visible and can be clicked on.
|
||||
self.scroll_to_element(css)
|
||||
self.q(css=css).click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def is_hint_notification_visible(self):
|
||||
"""
|
||||
Is the Hint Notification visible?
|
||||
"""
|
||||
return self.q(css='.notification.notification-hint').visible
|
||||
|
||||
def is_feedback_message_notification_visible(self):
|
||||
"""
|
||||
Is the Feedback Messaged notification visible
|
||||
"""
|
||||
return self.q(css='.wrapper-problem-response .message').visible
|
||||
|
||||
def is_save_notification_visible(self):
|
||||
"""
|
||||
Is the Save Notification Visible?
|
||||
"""
|
||||
return self.q(css='.notification.warning.notification-save').visible
|
||||
|
||||
def is_success_notification_visible(self):
|
||||
"""
|
||||
Is the Submit Notification Visible?
|
||||
"""
|
||||
return self.q(css='.notification.success.notification-submit').visible
|
||||
|
||||
def wait_for_feedback_message_visibility(self):
|
||||
"""
|
||||
Wait for the Feedback Message notification to be visible.
|
||||
"""
|
||||
self.wait_for_element_visibility('.wrapper-problem-response .message',
|
||||
'Waiting for the Feedback message to be visible')
|
||||
|
||||
def wait_for_save_notification(self):
|
||||
"""
|
||||
Wait for the Save Notification to be present
|
||||
"""
|
||||
self.wait_for_element_visibility('.notification.warning.notification-save',
|
||||
'Waiting for Save notification to be visible')
|
||||
self.wait_for(lambda: self.q(css='.notification.warning.notification-save').focused,
|
||||
'Waiting for the focus to be on the save notification')
|
||||
|
||||
def wait_for_gentle_alert_notification(self):
|
||||
"""
|
||||
Wait for the Gentle Alert Notification to be present
|
||||
"""
|
||||
self.wait_for_element_visibility('.notification.warning.notification-gentle-alert',
|
||||
'Waiting for Gentle Alert notification to be visible')
|
||||
self.wait_for(lambda: self.q(css='.notification.warning.notification-gentle-alert').focused,
|
||||
'Waiting for the focus to be on the gentle alert notification')
|
||||
|
||||
def wait_for_show_answer_notification(self):
|
||||
"""
|
||||
Wait for the show answer Notification to be present
|
||||
"""
|
||||
self.wait_for_element_visibility('.notification.general.notification-show-answer',
|
||||
'Waiting for Show Answer notification to be visible')
|
||||
self.wait_for(lambda: self.q(css='.notification.general.notification-show-answer').focused,
|
||||
'Waiting for the focus to be on the show answer notification')
|
||||
|
||||
def is_gentle_alert_notification_visible(self):
|
||||
"""
|
||||
Is the Gentle Alert Notification visible?
|
||||
"""
|
||||
return self.q(css='.notification.warning.notification-gentle-alert').visible
|
||||
|
||||
def is_reset_button_present(self):
|
||||
""" Check for the presence of the reset button. """
|
||||
return self.q(css='.problem .reset').present
|
||||
|
||||
def is_save_button_enabled(self):
|
||||
""" Is the Save button enabled """
|
||||
return self.q(css='.action .save').attrs('disabled') == [None]
|
||||
|
||||
def is_focus_on_problem_meta(self):
|
||||
"""
|
||||
Check for focus problem meta.
|
||||
"""
|
||||
return self.q(css='.problem-header').focused
|
||||
|
||||
def wait_for_focus_on_problem_meta(self):
|
||||
"""
|
||||
Waits for focus on Problem Meta section
|
||||
"""
|
||||
self.wait_for(
|
||||
promise_check_func=self.is_focus_on_problem_meta,
|
||||
description='Waiting for focus on Problem Meta section'
|
||||
)
|
||||
|
||||
def is_submit_disabled(self):
|
||||
"""
|
||||
Checks if the submit button is disabled
|
||||
"""
|
||||
disabled_attr = self.q(css='.problem .submit').attrs('disabled')[0]
|
||||
return disabled_attr == 'true'
|
||||
|
||||
def wait_for_submit_disabled(self):
|
||||
"""
|
||||
Waits until the Submit button becomes disabled.
|
||||
"""
|
||||
self.wait_for(self.is_submit_disabled, 'Waiting for submit to be enabled')
|
||||
|
||||
def wait_for_focus_on_submit_notification(self):
|
||||
"""
|
||||
Check for focus submit notification.
|
||||
"""
|
||||
|
||||
def focus_check():
|
||||
"""
|
||||
Checks whether or not the focus is on the notification-submit
|
||||
"""
|
||||
return self.q(css='.notification-submit').focused
|
||||
|
||||
self.wait_for(promise_check_func=focus_check, description='Waiting for the notification-submit to gain focus')
|
||||
|
||||
def wait_for_status_icon(self):
|
||||
"""
|
||||
wait for status icon
|
||||
"""
|
||||
self.wait_for_element_visibility('div.problem div.inputtype div .status', 'wait for status icon')
|
||||
|
||||
def wait_for_expected_status(self, status_selector, message):
|
||||
"""
|
||||
Waits for the expected status indicator.
|
||||
|
||||
Args:
|
||||
status_selector(str): status selector string.
|
||||
message(str): description of promise, to be logged.
|
||||
"""
|
||||
msg = u"Wait for status to be {}".format(message)
|
||||
self.wait_for_element_visibility(status_selector, msg)
|
||||
|
||||
def is_expected_status_visible(self, status_selector):
|
||||
"""
|
||||
check for the expected status indicator to be visible.
|
||||
|
||||
Args:
|
||||
status_selector(str): status selector string.
|
||||
"""
|
||||
return self.q(css=status_selector).visible
|
||||
|
||||
def wait_success_notification(self):
|
||||
"""
|
||||
Check for visibility of the success notification and icon.
|
||||
"""
|
||||
msg = "Wait for success notification to be visible"
|
||||
self.wait_for_element_visibility('.notification.success.notification-submit', msg)
|
||||
self.wait_for_element_visibility('.fa-check', "Waiting for success icon")
|
||||
self.wait_for_focus_on_submit_notification()
|
||||
|
||||
def wait_incorrect_notification(self):
|
||||
"""
|
||||
Check for visibility of the incorrect notification and icon.
|
||||
"""
|
||||
msg = "Wait for error notification to be visible"
|
||||
self.wait_for_element_visibility('.notification.error.notification-submit', msg)
|
||||
self.wait_for_element_visibility('.fa-close', "Waiting for incorrect notification icon")
|
||||
self.wait_for_focus_on_submit_notification()
|
||||
|
||||
def wait_partial_notification(self):
|
||||
"""
|
||||
Check for visibility of the partially visible notification and icon.
|
||||
"""
|
||||
msg = "Wait for partial correct notification to be visible"
|
||||
self.wait_for_element_visibility('.notification.success.notification-submit', msg)
|
||||
self.wait_for_element_visibility('.fa-asterisk', "Waiting for asterisk notification icon")
|
||||
self.wait_for_focus_on_submit_notification()
|
||||
|
||||
def wait_submitted_notification(self):
|
||||
"""
|
||||
Check for visibility of the "answer received" general notification and icon.
|
||||
"""
|
||||
msg = "Wait for submitted notification to be visible"
|
||||
self.wait_for_element_visibility('.notification.general.notification-submit', msg)
|
||||
self.wait_for_focus_on_submit_notification()
|
||||
|
||||
def click_hint(self, hint_index=0):
|
||||
"""
|
||||
Click the Hint button.
|
||||
|
||||
Arguments:
|
||||
hint_index (int): Index of a displayed hint
|
||||
"""
|
||||
click_css(self, '.problem .hint-button', require_notification=False)
|
||||
self.wait_for_focus_on_hint_notification(hint_index)
|
||||
|
||||
def wait_for_focus_on_hint_notification(self, hint_index=0):
|
||||
"""
|
||||
Wait for focus to be on the hint notification.
|
||||
|
||||
Arguments:
|
||||
hint_index (int): Index of a displayed hint
|
||||
"""
|
||||
css = u'.notification-hint .notification-message > ol > li.hint-index-{hint_index}'.format(
|
||||
hint_index=hint_index
|
||||
)
|
||||
self.wait_for(
|
||||
lambda: self.q(css=css).focused,
|
||||
'Waiting for the focus to be on the hint notification'
|
||||
)
|
||||
|
||||
def click_review_in_notification(self, notification_type):
|
||||
"""
|
||||
Click on the "Review" button within the visible notification.
|
||||
"""
|
||||
css_string = u'.notification.notification-{notification_type} .review-btn'.format(
|
||||
notification_type=notification_type
|
||||
)
|
||||
|
||||
# The review button cannot be clicked on until it is tabbed to, so first tab to it.
|
||||
# Multiple tabs may be required depending on the content (for instance, hints with links).
|
||||
def tab_until_review_focused():
|
||||
""" Tab until the review button is focused """
|
||||
self.browser.switch_to_active_element().send_keys(Keys.TAB)
|
||||
if self.q(css=css_string).focused:
|
||||
self.scroll_to_element(css_string)
|
||||
return self.q(css=css_string).focused
|
||||
|
||||
self.wait_for(
|
||||
tab_until_review_focused,
|
||||
'Waiting for the Review button to become focused'
|
||||
)
|
||||
self.wait_for_element_visibility(
|
||||
css_string,
|
||||
'Waiting for the button to be visible'
|
||||
)
|
||||
click_css(self, css_string, require_notification=False)
|
||||
|
||||
def get_hint_button_disabled_attr(self):
|
||||
""" Return the disabled attribute of all hint buttons (once hints are visible, there will be two). """
|
||||
return self.q(css='.problem .hint-button').attrs('disabled')
|
||||
click_css(self, '.problem .submit')
|
||||
|
||||
def click_choice(self, choice_value):
|
||||
"""
|
||||
@@ -424,218 +46,3 @@ class ProblemPage(PageObject):
|
||||
"""
|
||||
self.q(css='div.problem .choicegroup input[value="' + choice_value + '"]').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def is_correct(self):
|
||||
"""
|
||||
Is there a "correct" status showing?
|
||||
"""
|
||||
return self.q(css="div.problem div.capa_inputtype.textline div.correct span.status").is_present()
|
||||
|
||||
def simpleprob_is_correct(self):
|
||||
"""
|
||||
Is there a "correct" status showing? Works with simple problem types.
|
||||
"""
|
||||
return self.q(css="div.problem div.inputtype div.correct span.status").is_present()
|
||||
|
||||
def simpleprob_is_partially_correct(self):
|
||||
"""
|
||||
Is there a "partially correct" status showing? Works with simple problem types.
|
||||
"""
|
||||
return self.q(css="div.problem div.inputtype div.partially-correct span.status").is_present()
|
||||
|
||||
def simpleprob_is_incorrect(self):
|
||||
"""
|
||||
Is there an "incorrect" status showing? Works with simple problem types.
|
||||
"""
|
||||
return self.q(css="div.problem div.inputtype div.incorrect span.status").is_present()
|
||||
|
||||
def get_simpleprob_correctness(self):
|
||||
"""
|
||||
Returns the correctness status for a simple problem.
|
||||
|
||||
Given a simple problem, the method returns the correctness status.
|
||||
If there is no visible status, None is returned
|
||||
"""
|
||||
|
||||
if self.simpleprob_is_correct():
|
||||
return 'correct'
|
||||
elif self.simpleprob_is_incorrect():
|
||||
return 'incorrect'
|
||||
elif self.simpleprob_is_partially_correct():
|
||||
return 'partial'
|
||||
else:
|
||||
return None
|
||||
|
||||
def click_clarification(self, index=0):
|
||||
"""
|
||||
Click on an inline icon that can be included in problem text using an HTML <clarification> element:
|
||||
|
||||
Problem <clarification>clarification text hidden by an icon in rendering</clarification> Text
|
||||
"""
|
||||
self.q(css=u'div.problem .clarification:nth-child({index}) span[data-tooltip]'.format(index=index + 1)).click()
|
||||
|
||||
@property
|
||||
def visible_tooltip_text(self):
|
||||
"""
|
||||
Get the text seen in any tooltip currently visible on the page.
|
||||
"""
|
||||
self.wait_for_element_visibility('body > .tooltip', 'A tooltip is visible.')
|
||||
return self.q(css='body > .tooltip').text[0]
|
||||
|
||||
def is_solution_tag_present(self):
|
||||
"""
|
||||
Check if solution/explanation is shown.
|
||||
"""
|
||||
solution_selector = '.solution-span div.detailed-solution'
|
||||
return self.q(css=solution_selector).is_present()
|
||||
|
||||
def is_choice_highlighted(self, choice, choices_list, show_answer=True):
|
||||
"""
|
||||
Check if the given answer/choice is highlighted for choice group.
|
||||
|
||||
show_answer: if set, then requires each choice to be marked with a status.
|
||||
If not set, then the status can be elswhere in the problem.
|
||||
"""
|
||||
if show_answer:
|
||||
choice_status_xpath = (u'//fieldset/div[contains(@class, "field")][{{0}}]'
|
||||
u'/label[contains(@class, "choicegroup_{choice}")]'
|
||||
u'/span[contains(@class, "status {choice}")]'.format(choice=choice))
|
||||
any_status_xpath = u'//fieldset/div[contains(@class, "field")][{0}]/label/span'
|
||||
else:
|
||||
choice_status_xpath = (u'//fieldset/div[contains(@class, "field")][{{0}}]'
|
||||
u'/label[contains(@class, "choicegroup_{choice}")]'.format(choice=choice))
|
||||
any_status_xpath = u'//div[contains(@class, "indicator-container")]/span[contains(@class, "status")]'
|
||||
|
||||
for possible_choice in choices_list:
|
||||
if not self.q(xpath=choice_status_xpath.format(possible_choice)).is_present():
|
||||
return False
|
||||
|
||||
# Check that there is only a single status span, as there were some bugs with multiple
|
||||
# spans (with various classes) being appended.
|
||||
if not len(self.q(xpath=any_status_xpath.format(possible_choice)).results) == 1:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def is_correct_choice_highlighted(self, correct_choices, show_answer=True):
|
||||
"""
|
||||
Check if correct answer/choice highlighted for choice group.
|
||||
"""
|
||||
return self.is_choice_highlighted('correct', correct_choices, show_answer)
|
||||
|
||||
def is_submitted_choice_highlighted(self, correct_choices):
|
||||
"""
|
||||
Check if submitted answer/choice highlighted for choice group.
|
||||
"""
|
||||
return self.is_choice_highlighted('submitted', correct_choices)
|
||||
|
||||
@property
|
||||
def problem_question(self):
|
||||
"""
|
||||
Return the question text of the problem.
|
||||
"""
|
||||
return self.q(css="div.problem .wrapper-problem-response legend").text[0]
|
||||
|
||||
@property
|
||||
def problem_question_descriptions(self):
|
||||
"""
|
||||
Return a list of question descriptions of the problem.
|
||||
"""
|
||||
return self.q(css="div.problem .wrapper-problem-response .question-description").text
|
||||
|
||||
@property
|
||||
def problem_progress_graded_value(self):
|
||||
"""
|
||||
Return problem progress text which contains weight of problem, if it is graded, and the student's current score.
|
||||
"""
|
||||
self.wait_for_element_visibility('.problem-progress', "Problem progress is visible")
|
||||
return self.q(css='.problem-progress').text[0]
|
||||
|
||||
@property
|
||||
def status_sr_text(self):
|
||||
"""
|
||||
Returns the text in the special "sr" region used for display status.
|
||||
"""
|
||||
return self.q(css='#reader-feedback').text[0]
|
||||
|
||||
@property
|
||||
def submission_feedback(self):
|
||||
"""
|
||||
Returns the submission feedback of the problem
|
||||
"""
|
||||
return self.q(css='div[class="submission-feedback"]').text[0].split('\n')[0]
|
||||
|
||||
@property
|
||||
def answer(self):
|
||||
"""
|
||||
Returns the answer of the problem
|
||||
"""
|
||||
return self.q(css='p[class="answer"]').text[0]
|
||||
|
||||
@property
|
||||
def score_notification(self):
|
||||
"""
|
||||
Returns the score after the submission of answer
|
||||
"""
|
||||
self.wait_for_element_visibility('.notification-submit .notification-message', 'Problem score is visible')
|
||||
return self.q(css='.notification-submit .notification-message').text[0]
|
||||
|
||||
def is_present(self, selector):
|
||||
"""
|
||||
Checks for the presence of the locator
|
||||
"""
|
||||
return self.q(css=selector).present
|
||||
|
||||
|
||||
class DragAndDropPage(PageObject):
|
||||
"""
|
||||
View for a Drag & Drop problem.
|
||||
"""
|
||||
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='.xblock-student_view').present
|
||||
|
||||
def is_submit_disabled(self):
|
||||
"""
|
||||
Checks if the submit button is disabled for Drag & Drop problem.
|
||||
"""
|
||||
disabled_attr = self.q(css='.submit-answer-button').attrs('disabled')[0]
|
||||
return disabled_attr == 'true'
|
||||
|
||||
def is_present(self, selector):
|
||||
"""
|
||||
Checks for the presence of the locator.
|
||||
"""
|
||||
return self.q(css=selector).present
|
||||
|
||||
def is_submit_button_present(self):
|
||||
"""
|
||||
Verifies if the submit button is present for DnD problems
|
||||
with assessment mode.
|
||||
"""
|
||||
return self.is_present('.submit-answer-button')
|
||||
|
||||
def _get_item_by_value(self, item_value):
|
||||
"""
|
||||
Get the item that will be placed onto a zone.
|
||||
"""
|
||||
return self.q(xpath=(".//div[@data-value='{item_id}']".format(item_id=item_value)))[0]
|
||||
|
||||
def _get_zone_by_id(self, zone_id):
|
||||
"""
|
||||
Get zone where the item will be placed.
|
||||
"""
|
||||
zones_container = self.browser.find_element_by_css_selector('.target')
|
||||
return zones_container.find_elements_by_xpath(".//div[@data-uid='{zone_id}']".format(zone_id=zone_id))[0]
|
||||
|
||||
def drag_item_to_zone(self, item_value, zone_id):
|
||||
"""
|
||||
Drag item to desired zone using mouse interaction.
|
||||
"""
|
||||
element = self._get_item_by_value(item_value)
|
||||
target = self._get_zone_by_id(zone_id)
|
||||
action_chains = ActionChains(self.browser)
|
||||
action_chains.drag_and_drop(element, target).perform()
|
||||
self.wait_for_ajax()
|
||||
|
||||
@@ -3,8 +3,6 @@ Student progress page
|
||||
"""
|
||||
|
||||
|
||||
from six.moves import map
|
||||
|
||||
from common.test.acceptance.pages.lms.course_page import CoursePage
|
||||
|
||||
|
||||
@@ -22,61 +20,14 @@ class ProgressPage(CoursePage):
|
||||
)
|
||||
return is_present
|
||||
|
||||
@property
|
||||
def grading_formats(self):
|
||||
return [label.replace(' Scores:', '') for label in self.q(css=".scores dt").text]
|
||||
|
||||
def section_score(self, chapter, section):
|
||||
def x_tick_sr_text(self, tick_index):
|
||||
"""
|
||||
Return a list of (points, max_points) tuples representing the
|
||||
aggregate score for the section.
|
||||
|
||||
Example:
|
||||
page.section_score('Week 1', 'Lesson 1') --> (2, 5)
|
||||
|
||||
Returns `None` if no such chapter and section can be found.
|
||||
Return an array of the sr text for a specific x-Axis tick on the
|
||||
progress chart.
|
||||
"""
|
||||
# Find the index of the section in the chapter
|
||||
chapter_index = self._chapter_index(chapter)
|
||||
if chapter_index is None:
|
||||
return None
|
||||
|
||||
section_index = self._section_index(chapter_index, section)
|
||||
if section_index is None:
|
||||
return None
|
||||
|
||||
# Retrieve the scores for the section
|
||||
return self._aggregate_section_score(chapter_index, section_index)
|
||||
|
||||
def scores(self, chapter, section):
|
||||
"""
|
||||
Return a list of (points, max_points) tuples representing the scores
|
||||
for the section.
|
||||
|
||||
Example:
|
||||
page.scores('Week 1', 'Lesson 1') --> [(2, 4), (0, 1)]
|
||||
|
||||
Returns `None` if no such chapter and section can be found.
|
||||
"""
|
||||
|
||||
# Find the index of the section in the chapter
|
||||
chapter_index = self._chapter_index(chapter)
|
||||
if chapter_index is None:
|
||||
return None
|
||||
|
||||
section_index = self._section_index(chapter_index, section)
|
||||
if section_index is None:
|
||||
return None
|
||||
|
||||
# Retrieve the scores for the section
|
||||
return self._section_scores(chapter_index, section_index)
|
||||
|
||||
def text_on_page(self, text):
|
||||
"""
|
||||
Return whether the given text appears
|
||||
on the page.
|
||||
"""
|
||||
return text in self.q(css=".view-in-course").html[0]
|
||||
selector = self.q(css='#grade-detail-graph .tickLabel')[tick_index]
|
||||
sr_fields = selector.find_elements_by_class_name('sr')
|
||||
return [field.text for field in sr_fields]
|
||||
|
||||
def x_tick_label(self, tick_index):
|
||||
"""
|
||||
@@ -87,15 +38,6 @@ class ProgressPage(CoursePage):
|
||||
tick_label = selector.find_elements_by_tag_name('span')[0]
|
||||
return [tick_label.text, tick_label.get_attribute('aria-hidden')]
|
||||
|
||||
def x_tick_sr_text(self, tick_index):
|
||||
"""
|
||||
Return an array of the sr text for a specific x-Axis tick on the
|
||||
progress chart.
|
||||
"""
|
||||
selector = self.q(css='#grade-detail-graph .tickLabel')[tick_index]
|
||||
sr_fields = selector.find_elements_by_class_name('sr')
|
||||
return [field.text for field in sr_fields]
|
||||
|
||||
def y_tick_label(self, tick_index):
|
||||
"""
|
||||
Returns the label for the Y-axis tick index,
|
||||
@@ -113,85 +55,3 @@ class ProgressPage(CoursePage):
|
||||
selector = self.q(css='#grade-detail-graph .overallGrade')[0]
|
||||
label = selector.find_elements_by_class_name('sr')[0]
|
||||
return [label.text, selector.text]
|
||||
|
||||
def _chapter_index(self, title):
|
||||
"""
|
||||
Return the CSS index of the chapter with `title`.
|
||||
Returns `None` if it cannot find such a chapter.
|
||||
"""
|
||||
chapter_css = '.chapters>section h3'
|
||||
chapter_titles = self.q(css=chapter_css).map(lambda el: el.text.lower().strip()).results
|
||||
|
||||
try:
|
||||
# CSS indices are 1-indexed, so add one to the list index
|
||||
return chapter_titles.index(title.lower()) + 1
|
||||
except ValueError:
|
||||
self.warning(u"Could not find chapter '{0}'".format(title))
|
||||
return None
|
||||
|
||||
def _section_index(self, chapter_index, title):
|
||||
"""
|
||||
Return the CSS index of the section with `title` in the chapter at `chapter_index`.
|
||||
Returns `None` if it can't find such a section.
|
||||
"""
|
||||
|
||||
# This is a hideous CSS selector that means:
|
||||
# Get the links containing the section titles in `chapter_index`.
|
||||
# The link text is the section title.
|
||||
section_css = u'.chapters>section:nth-of-type({0}) .sections div .hd a'.format(chapter_index)
|
||||
section_titles = self.q(css=section_css).map(lambda el: el.text.lower().strip()).results
|
||||
|
||||
# The section titles also contain "n of m possible points" on the second line
|
||||
# We have to remove this to find the right title
|
||||
section_titles = [t.split('\n')[0] for t in section_titles]
|
||||
|
||||
# Some links are blank, so remove them
|
||||
section_titles = [t for t in section_titles if t]
|
||||
|
||||
try:
|
||||
# CSS indices are 1-indexed, so add one to the list index
|
||||
return section_titles.index(title.lower()) + 1
|
||||
except ValueError:
|
||||
self.warning(u"Could not find section '{0}'".format(title))
|
||||
return None
|
||||
|
||||
def _aggregate_section_score(self, chapter_index, section_index):
|
||||
"""
|
||||
Return a tuple of the form `(points, max_points)` representing
|
||||
the aggregate score for the specified chapter and section.
|
||||
"""
|
||||
score_css = u".chapters>section:nth-of-type({0}) .sections>div:nth-of-type({1}) .hd>span".format(
|
||||
chapter_index, section_index
|
||||
|
||||
)
|
||||
text_scores = self.q(css=score_css).text
|
||||
assert len(text_scores) == 1
|
||||
text_score = text_scores[0]
|
||||
text_score = text_score.split()[0] # strip off percentage, if present
|
||||
|
||||
assert (text_score[0], text_score[-1]) == ('(', ')')
|
||||
text_score = text_score.strip('()')
|
||||
|
||||
assert '/' in text_score
|
||||
score = tuple(int(x) for x in text_score.split('/'))
|
||||
assert len(score) == 2
|
||||
return score
|
||||
|
||||
def _section_scores(self, chapter_index, section_index):
|
||||
"""
|
||||
Return a list of `(points, max_points)` tuples representing
|
||||
the scores in the specified chapter and section.
|
||||
|
||||
`chapter_index` and `section_index` start at 1.
|
||||
"""
|
||||
# This is CSS selector means:
|
||||
# Get the scores for the chapter at `chapter_index` and the section at `section_index`
|
||||
# Example text of the retrieved elements: "0/1"
|
||||
score_css = u".chapters>section:nth-of-type({0}) .sections>div:nth-of-type({1}) .scores>dd".format(
|
||||
chapter_index, section_index
|
||||
)
|
||||
|
||||
text_scores = self.q(css=score_css).text
|
||||
|
||||
# Convert text scores to tuples of (points, max_points)
|
||||
return [tuple(map(int, score.split('/'))) for score in text_scores]
|
||||
|
||||
@@ -43,26 +43,6 @@ class StaffPreviewPage(PageObject):
|
||||
"""
|
||||
return self.q(css=self.VIEW_MODE_OPTIONS_CSS).filter(lambda el: el.is_selected()).first.text[0]
|
||||
|
||||
def set_staff_view_mode(self, view_mode):
|
||||
"""
|
||||
Set the current view mode, e.g. "Staff", "Learner" or a content group.
|
||||
"""
|
||||
self.q(css=self.VIEW_MODE_OPTIONS_CSS).filter(lambda el: el.text.strip() == view_mode).first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def set_staff_view_mode_specific_student(self, username_or_email):
|
||||
"""
|
||||
Set the current preview mode to "Specific learner" with the given username or email
|
||||
"""
|
||||
required_mode = "Specific learner"
|
||||
if self.staff_view_mode != required_mode:
|
||||
self.q(css=self.VIEW_MODE_OPTIONS_CSS).filter(lambda el: el.text == required_mode).first.click()
|
||||
# Use a script here because .clear() + .send_keys() triggers unwanted behavior if a username is already set
|
||||
self.browser.execute_script(
|
||||
'$(".action-preview-username").val("{}").blur().change();'.format(username_or_email)
|
||||
)
|
||||
self.wait_for_ajax()
|
||||
|
||||
|
||||
class StaffCoursewarePage(CoursewarePage, StaffPreviewPage):
|
||||
"""
|
||||
@@ -79,81 +59,3 @@ class StaffCoursewarePage(CoursewarePage, StaffPreviewPage):
|
||||
if not CoursewarePage.is_browser_on_page(self):
|
||||
return False
|
||||
return StaffPreviewPage.is_browser_on_page(self)
|
||||
|
||||
def open_staff_debug_info(self):
|
||||
"""
|
||||
Open the staff debug window
|
||||
Return the page object for it.
|
||||
"""
|
||||
self.q(css='a.instructor-info-action').first.click()
|
||||
staff_debug_page = StaffDebugPage(self.browser)
|
||||
staff_debug_page.wait_for_page()
|
||||
return staff_debug_page
|
||||
|
||||
def answer_problem(self):
|
||||
"""
|
||||
Answers the problem to give state that we can clean
|
||||
"""
|
||||
self.q(css='input.check').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def load_problem_via_ajax(self):
|
||||
"""
|
||||
Load problem via ajax by clicking next.
|
||||
"""
|
||||
self.q(css="li.next").click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
|
||||
class StaffDebugPage(PageObject):
|
||||
"""
|
||||
Staff Debug modal
|
||||
"""
|
||||
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='.staff-modal').present
|
||||
|
||||
def reset_attempts(self, user=None):
|
||||
"""
|
||||
This clicks on the reset attempts link with an optionally
|
||||
specified user.
|
||||
"""
|
||||
if user:
|
||||
self.q(css='input[id^=sd_fu_]').first.fill(user)
|
||||
self.q(css='.staff-modal .staff-debug-reset').click()
|
||||
|
||||
def delete_state(self, user=None):
|
||||
"""
|
||||
This delete's a student's state for the problem
|
||||
"""
|
||||
if user:
|
||||
self.q(css='input[id^=sd_fu_]').first.fill(user)
|
||||
self.q(css='.staff-modal .staff-debug-sdelete').first.click()
|
||||
|
||||
def rescore(self, user=None):
|
||||
"""
|
||||
This clicks on the reset attempts link with an optionally
|
||||
specified user.
|
||||
"""
|
||||
if user:
|
||||
self.q(css='input[id^=sd_fu_]').first.fill(user)
|
||||
self.q(css='.staff-modal .staff-debug-rescore').click()
|
||||
|
||||
def rescore_if_higher(self, user=None):
|
||||
"""
|
||||
This clicks on the reset attempts link with an optionally
|
||||
specified user.
|
||||
"""
|
||||
if user:
|
||||
self.q(css='input[id^=sd_fu_]').first.fill(user)
|
||||
self.q(css='.staff-modal .staff-debug-rescore-if-higher').click()
|
||||
|
||||
@property
|
||||
def idash_msg(self):
|
||||
"""
|
||||
Returns the value of #idash_msg
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
return self.q(css='#idash_msg').text
|
||||
|
||||
@@ -14,8 +14,8 @@ class TabNavPage(PageObject):
|
||||
|
||||
url = None
|
||||
|
||||
def is_using_v1_style_tabs(self):
|
||||
return self.q(css='ol.course-tabs').present
|
||||
# def is_using_v1_style_tabs(self):
|
||||
# return self.q(css='ol.course-tabs').present
|
||||
|
||||
def is_using_boostrap_style_tabs(self):
|
||||
return self.q(css='ul.navbar-nav').present
|
||||
@@ -44,24 +44,6 @@ class TabNavPage(PageObject):
|
||||
self.wait_for_page()
|
||||
self._is_on_tab_promise(tab_name).fulfill()
|
||||
|
||||
def mathjax_has_rendered(self):
|
||||
"""
|
||||
Check that MathJax has rendered in tab content
|
||||
"""
|
||||
mathjax_container = self.q(css=".static_tab_wrapper .MathJax")
|
||||
EmptyPromise(
|
||||
lambda: mathjax_container.present and mathjax_container.visible,
|
||||
"MathJax is not visible"
|
||||
).fulfill()
|
||||
|
||||
def is_on_tab(self, tab_name):
|
||||
"""
|
||||
Return a boolean indicating whether the current tab is `tab_name`.
|
||||
Because this is a public method, it checks that we're on the right page
|
||||
before accessing the DOM.
|
||||
"""
|
||||
return self._is_on_tab(tab_name)
|
||||
|
||||
def _tab_css(self, tab_name):
|
||||
"""
|
||||
Return the CSS to click for `tab_name`.
|
||||
@@ -125,10 +107,3 @@ class TabNavPage(PageObject):
|
||||
lambda: self._is_on_tab(tab_name),
|
||||
u"{0} is the current tab".format(tab_name)
|
||||
)
|
||||
|
||||
def has_new_post_button_visible_on_tab(self):
|
||||
"""
|
||||
Check if new post button present and visible on course tab page
|
||||
"""
|
||||
new_post_btn = self.q(css='ol.course-tabs .new-post-btn')
|
||||
return new_post_btn.present and new_post_btn.visible
|
||||
|
||||
@@ -1,602 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Teams pages.
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.pages.common.paging import PaginatedUIMixin
|
||||
from common.test.acceptance.pages.common.utils import click_css, confirm_prompt
|
||||
from common.test.acceptance.pages.lms.course_page import CoursePage
|
||||
from common.test.acceptance.pages.lms.discussion import InlineDiscussionPage
|
||||
from common.test.acceptance.pages.lms.fields import FieldsMixin
|
||||
|
||||
TOPIC_CARD_CSS = 'div.wrapper-card-core'
|
||||
CARD_TITLE_CSS = 'h3.card-title'
|
||||
MY_TEAMS_BUTTON_CSS = '.nav-item[data-index="0"]'
|
||||
BROWSE_BUTTON_CSS = '.nav-item[data-index="1"]'
|
||||
TEAMS_LINK_CSS = '.action-view'
|
||||
TEAMS_HEADER_CSS = '.teams-header'
|
||||
CREATE_TEAM_LINK_CSS = '.create-team'
|
||||
|
||||
|
||||
class TeamCardsMixin(object):
|
||||
"""Provides common operations on the team card component."""
|
||||
|
||||
def _bounded_selector(self, css):
|
||||
"""Bind the CSS to a particular tabpanel (e.g. My Teams or Browse)."""
|
||||
return u'{tabpanel_id} {css}'.format(tabpanel_id=getattr(self, 'tabpanel_id', ''), css=css)
|
||||
|
||||
def view_first_team(self):
|
||||
"""Click the 'view' button of the first team card on the page."""
|
||||
self.q(css=self._bounded_selector('a.action-view')).first.click()
|
||||
|
||||
@property
|
||||
def team_cards(self):
|
||||
"""Get all the team cards on the page."""
|
||||
return self.q(css=self._bounded_selector('.team-card'))
|
||||
|
||||
@property
|
||||
def team_names(self):
|
||||
"""Return the names of each team on the page."""
|
||||
return self.q(css=self._bounded_selector('h3.card-title')).map(lambda e: e.text).results
|
||||
|
||||
@property
|
||||
def team_descriptions(self):
|
||||
"""Return the names of each team on the page."""
|
||||
return self.q(css=self._bounded_selector('p.card-description')).map(lambda e: e.text).results
|
||||
|
||||
@property
|
||||
def team_memberships(self):
|
||||
"""Return the team memberships text for each card on the page."""
|
||||
return self.q(css=self._bounded_selector('.member-count')).map(lambda e: e.text).results
|
||||
|
||||
|
||||
class BreadcrumbsMixin(object):
|
||||
"""Provides common operations on teams page breadcrumb links."""
|
||||
|
||||
@property
|
||||
def header_page_breadcrumbs(self):
|
||||
"""Get the page breadcrumb text displayed by the page header"""
|
||||
return self.q(css='.page-header .breadcrumbs')[0].text
|
||||
|
||||
def click_all_topics(self):
|
||||
""" Click on the "All Topics" breadcrumb """
|
||||
self.q(css='a.nav-item').filter(text='All Topics')[0].click()
|
||||
|
||||
def click_specific_topic(self, topic):
|
||||
""" Click on the breadcrumb for a specific topic """
|
||||
self.q(css='a.nav-item').filter(text=topic)[0].click()
|
||||
|
||||
|
||||
class TeamsPage(CoursePage, BreadcrumbsMixin):
|
||||
"""
|
||||
Teams page/tab.
|
||||
"""
|
||||
url_path = "teams"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
""" Checks if teams page is being viewed """
|
||||
return self.q(css='body.view-teams').present
|
||||
|
||||
def get_body_text(self):
|
||||
""" Returns the current dummy text. This will be changed once there is more content on the page. """
|
||||
main_page_content_css = '.page-content-main'
|
||||
self.wait_for(
|
||||
lambda: len(self.q(css=main_page_content_css).text) == 1,
|
||||
description="Body text is present"
|
||||
)
|
||||
return self.q(css=main_page_content_css).text[0]
|
||||
|
||||
def active_tab(self):
|
||||
""" Get the active tab. """
|
||||
return self.q(css='.is-active').attrs('data-url')[0]
|
||||
|
||||
def browse_topics(self):
|
||||
""" View the Browse tab of the Teams page. """
|
||||
self.q(css=BROWSE_BUTTON_CSS).click()
|
||||
|
||||
def verify_team_count_in_first_topic(self, expected_count):
|
||||
"""
|
||||
Verify that the team count on the first topic card in the topic list is correct
|
||||
(browse topics page).
|
||||
"""
|
||||
self.wait_for(
|
||||
lambda: self.q(css='.team-count')[0].text == "0 Teams" if expected_count == 0 else "1 Team",
|
||||
description="Team count text on topic is wrong"
|
||||
)
|
||||
|
||||
def verify_topic_team_count(self, expected_count):
|
||||
""" Verify the number of teams listed on the topic page (browse teams within topic). """
|
||||
self.wait_for(
|
||||
lambda: len(self.q(css='.team-card')) == expected_count,
|
||||
description="Expected number of teams is wrong"
|
||||
)
|
||||
|
||||
def verify_my_team_count(self, expected_count):
|
||||
""" Verify the number of teams on 'My Team'. """
|
||||
|
||||
# Click to "My Team" and verify that it contains the expected number of teams.
|
||||
self.q(css=MY_TEAMS_BUTTON_CSS).click()
|
||||
self.wait_for_ajax()
|
||||
self.wait_for(
|
||||
lambda: len(self.q(css='.team-card')) == expected_count,
|
||||
description="Expected number of teams is wrong"
|
||||
)
|
||||
|
||||
def click_all_topics(self):
|
||||
""" Click on the "All Topics" breadcrumb """
|
||||
self.q(css='a.nav-item').filter(text='All Topics')[0].click()
|
||||
|
||||
def click_specific_topic(self, topic):
|
||||
""" Click on the breadcrumb for a specific topic """
|
||||
self.q(css='a.nav-item').filter(text=topic)[0].click()
|
||||
|
||||
@property
|
||||
def warning_message(self):
|
||||
"""Return the text of the team warning message."""
|
||||
return self.q(css='.warning').results[0].text
|
||||
|
||||
|
||||
class MyTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin):
|
||||
"""
|
||||
The 'My Teams' tab of the Teams page.
|
||||
"""
|
||||
|
||||
url_path = "teams/#my-teams"
|
||||
tabpanel_id = '#tabpanel-my-teams'
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check if the "My Teams" tab is being viewed."""
|
||||
button_classes = self.q(css=MY_TEAMS_BUTTON_CSS).attrs('class')
|
||||
if len(button_classes) == 0:
|
||||
return False
|
||||
return 'is-active' in button_classes[0]
|
||||
|
||||
|
||||
class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
|
||||
"""
|
||||
The 'Browse' tab of the Teams page.
|
||||
"""
|
||||
|
||||
url_path = "teams/#browse"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check if the Browse tab is being viewed."""
|
||||
# First off, you need to make sure that you're on the Teams page.
|
||||
if not self.q(css='.teams-main').visible:
|
||||
return False
|
||||
button_classes = self.q(css=BROWSE_BUTTON_CSS).attrs('class')
|
||||
if len(button_classes) == 0:
|
||||
return False
|
||||
return 'is-active' in button_classes[0]
|
||||
|
||||
@property
|
||||
def topic_cards(self):
|
||||
"""Return a list of the topic cards present on the page."""
|
||||
return self.q(css=TOPIC_CARD_CSS).results
|
||||
|
||||
@property
|
||||
def topic_names(self):
|
||||
"""Return a list of the topic names present on the page."""
|
||||
return self.q(css='#tabpanel-browse ' + CARD_TITLE_CSS).map(lambda e: e.text).results
|
||||
|
||||
@property
|
||||
def topic_descriptions(self):
|
||||
"""Return a list of the topic descriptions present on the page."""
|
||||
return self.q(css='p.card-description').map(lambda e: e.text).results
|
||||
|
||||
def browse_teams_for_topic(self, topic_name):
|
||||
"""
|
||||
Show the teams list for `topic_name`.
|
||||
"""
|
||||
self.q(css=TEAMS_LINK_CSS).filter(
|
||||
text=u'View Teams in the {topic_name} Topic'.format(topic_name=topic_name)
|
||||
)[0].click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def sort_topics_by(self, sort_order):
|
||||
"""Sort the list of topics by the given `sort_order`."""
|
||||
self.q(
|
||||
css=u'#paging-header-select option[value={sort_order}]'.format(sort_order=sort_order)
|
||||
).click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
|
||||
class BaseTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin, BreadcrumbsMixin):
|
||||
"""
|
||||
The paginated UI for browsing teams within a Topic on the Teams
|
||||
page.
|
||||
"""
|
||||
def __init__(self, browser, course_id, topic):
|
||||
"""
|
||||
Note that `topic` is a dict representation of a topic following
|
||||
the same convention as a course module's topic.
|
||||
"""
|
||||
super(BaseTeamsPage, self).__init__(browser, course_id)
|
||||
self.browser = browser
|
||||
self.course_id = course_id
|
||||
self.topic = topic
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check if we're on a teams list page for a particular topic."""
|
||||
has_correct_url = self.url.endswith(self.url_path)
|
||||
teams_list_view_present = self.q(css='.teams-main').present
|
||||
return has_correct_url and teams_list_view_present
|
||||
|
||||
@property
|
||||
def header_name(self):
|
||||
"""Get the topic name displayed by the page header"""
|
||||
return self.q(css=TEAMS_HEADER_CSS + ' .page-title')[0].text
|
||||
|
||||
@property
|
||||
def header_description(self):
|
||||
"""Get the topic description displayed by the page header"""
|
||||
return self.q(css=TEAMS_HEADER_CSS + ' .page-description')[0].text
|
||||
|
||||
@property
|
||||
def sort_order(self):
|
||||
"""Return the current sort order on the page."""
|
||||
return self.q(
|
||||
css='#paging-header-select option'
|
||||
).filter(
|
||||
lambda e: e.is_selected()
|
||||
).results[0].text.strip()
|
||||
|
||||
@property
|
||||
def team_names(self):
|
||||
"""Get all the team names on the page."""
|
||||
return self.q(css=CARD_TITLE_CSS).map(lambda e: e.text).results
|
||||
|
||||
def click_create_team_link(self):
|
||||
""" Click on create team link."""
|
||||
query = self.q(css=CREATE_TEAM_LINK_CSS)
|
||||
if query.present:
|
||||
query.first.click()
|
||||
|
||||
# This will bring you to the team management page
|
||||
team_management_page = TeamManagementPage(self.browser, self.course_id, self.topic)
|
||||
team_management_page.wait_for_page()
|
||||
|
||||
def click_search_team_link(self):
|
||||
""" Click on create team link."""
|
||||
query = self.q(css='.search-team-descriptions')
|
||||
if query.present:
|
||||
query.first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def click_browse_all_teams_link(self):
|
||||
""" Click on browse team link."""
|
||||
query = self.q(css='.browse-teams')
|
||||
if query.present:
|
||||
query.first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def sort_teams_by(self, sort_order):
|
||||
"""Sort the list of teams by the given `sort_order`."""
|
||||
self.q(
|
||||
css=u'#paging-header-select option[value={sort_order}]'.format(sort_order=sort_order)
|
||||
).click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
@property
|
||||
def _showing_search_results(self):
|
||||
"""
|
||||
Returns true if showing search results.
|
||||
"""
|
||||
return self.header_description.startswith(u"Showing results for")
|
||||
|
||||
def search(self, string):
|
||||
"""
|
||||
Searches for the specified string, and returns a SearchTeamsPage
|
||||
representing the search results page.
|
||||
"""
|
||||
self.q(css='.search-field').first.fill(string)
|
||||
self.q(css='.action-search').first.click()
|
||||
self.wait_for_ajax()
|
||||
self.wait_for(
|
||||
lambda: self._showing_search_results,
|
||||
description="Showing search results"
|
||||
)
|
||||
page = SearchTeamsPage(self.browser, self.course_id, self.topic)
|
||||
page.wait_for_page()
|
||||
return page
|
||||
|
||||
|
||||
class BrowseTeamsPage(BaseTeamsPage):
|
||||
"""
|
||||
The paginated UI for browsing teams within a Topic on the Teams
|
||||
page.
|
||||
"""
|
||||
def __init__(self, browser, course_id, topic):
|
||||
super(BrowseTeamsPage, self).__init__(browser, course_id, topic)
|
||||
self.url_path = "teams/#topics/{topic_id}".format(topic_id=self.topic['id'])
|
||||
|
||||
|
||||
class SearchTeamsPage(BaseTeamsPage):
|
||||
"""
|
||||
The paginated UI for showing team search results.
|
||||
page.
|
||||
"""
|
||||
def __init__(self, browser, course_id, topic):
|
||||
super(SearchTeamsPage, self).__init__(browser, course_id, topic)
|
||||
self.url_path = "teams/#topics/{topic_id}/search".format(topic_id=self.topic['id'])
|
||||
|
||||
|
||||
class TeamManagementPage(CoursePage, FieldsMixin, BreadcrumbsMixin):
|
||||
"""
|
||||
Team page for creation, editing, and deletion.
|
||||
"""
|
||||
def __init__(self, browser, course_id, topic):
|
||||
"""
|
||||
Set up `self.url_path` on instantiation, since it dynamically
|
||||
reflects the current topic. Note that `topic` is a dict
|
||||
representation of a topic following the same convention as a
|
||||
course module's topic.
|
||||
"""
|
||||
super(TeamManagementPage, self).__init__(browser, course_id)
|
||||
self.topic = topic
|
||||
self.url_path = "teams/#topics/{topic_id}/create-team".format(topic_id=self.topic['id'])
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check if we're on the create team page for a particular topic."""
|
||||
fields_css = '.team-edit-fields'
|
||||
button_sr_css = '.action.action-primary > .sr'
|
||||
return self.q(css=fields_css).present and self.q(css=button_sr_css).visible
|
||||
|
||||
@property
|
||||
def header_page_name(self):
|
||||
"""Get the page name displayed by the page header"""
|
||||
return self.q(css='.page-header .page-title')[0].text
|
||||
|
||||
@property
|
||||
def header_page_description(self):
|
||||
"""Get the page description displayed by the page header"""
|
||||
return self.q(css='.page-header .page-description')[0].text
|
||||
|
||||
@property
|
||||
def validation_message_text(self):
|
||||
"""Get the error message text"""
|
||||
return self.q(css='.create-team.wrapper-msg .copy')[0].text
|
||||
|
||||
def create_team(self, name='Team Name', description='Team description.'):
|
||||
"""Create a new team"""
|
||||
self.value_for_text_field(field_id='name', value=name, press_enter=False)
|
||||
self.set_value_for_textarea_field(
|
||||
field_id='description',
|
||||
value=description
|
||||
)
|
||||
self.submit_form()
|
||||
|
||||
def submit_form(self):
|
||||
"""Click on create team button"""
|
||||
self.q(css='.create-team .action-primary').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def cancel_team(self):
|
||||
"""Click on cancel team button"""
|
||||
self.q(css='.create-team .action-cancel').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
@property
|
||||
def delete_team_button(self):
|
||||
"""Returns the 'delete team' button."""
|
||||
return self.q(css='.action-delete').first
|
||||
|
||||
def click_membership_button(self):
|
||||
"""Clicks the 'edit membership' button"""
|
||||
self.q(css='.action-edit-members').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
@property
|
||||
def membership_button_present(self):
|
||||
"""Checks if the edit membership button is present"""
|
||||
return self.q(css='.action-edit-members').present
|
||||
|
||||
|
||||
class EditMembershipPage(CoursePage):
|
||||
"""
|
||||
Staff or discussion-privileged user page to remove troublesome or inactive
|
||||
students from a team
|
||||
"""
|
||||
def __init__(self, browser, course_id, team):
|
||||
"""
|
||||
Set up `self.url_path` on instantiation, since it dynamically
|
||||
reflects the current team.
|
||||
"""
|
||||
super(EditMembershipPage, self).__init__(browser, course_id)
|
||||
self.team = team
|
||||
self.url_path = "teams/#teams/{topic_id}/{team_id}/edit-team/manage-members".format(
|
||||
topic_id=self.team['topic_id'], team_id=self.team['id']
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check if we're on the team membership page for a particular team."""
|
||||
self.wait_for_ajax()
|
||||
|
||||
if self.q(css='.edit-members').present:
|
||||
return True
|
||||
empty_query = self.q(css='.teams-main>.page-content>p').first
|
||||
return (
|
||||
len(empty_query.results) > 0 and
|
||||
empty_query[0].text == "This team does not have any members."
|
||||
)
|
||||
|
||||
@property
|
||||
def team_members(self):
|
||||
"""Returns the number of team members shown on the page."""
|
||||
return len(self.q(css='.team-member'))
|
||||
|
||||
def click_first_remove(self):
|
||||
"""Clicks the remove link on the first member listed."""
|
||||
self.q(css='.action-remove-member').first.click()
|
||||
|
||||
def confirm_delete_membership_dialog(self):
|
||||
"""Click 'delete' on the warning dialog."""
|
||||
confirm_prompt(self, require_notification=False)
|
||||
self.wait_for_ajax()
|
||||
|
||||
def cancel_delete_membership_dialog(self):
|
||||
"""Click 'delete' on the warning dialog."""
|
||||
confirm_prompt(self, cancel=True)
|
||||
|
||||
|
||||
class TeamPage(CoursePage, PaginatedUIMixin, BreadcrumbsMixin):
|
||||
"""
|
||||
The page for a specific Team within the Teams tab
|
||||
"""
|
||||
def __init__(self, browser, course_id, team=None):
|
||||
"""
|
||||
Set up `self.url_path` on instantiation, since it dynamically
|
||||
reflects the current team.
|
||||
"""
|
||||
super(TeamPage, self).__init__(browser, course_id)
|
||||
self.team = team
|
||||
if self.team:
|
||||
self.url_path = "teams/#teams/{topic_id}/{team_id}".format(
|
||||
topic_id=self.team['topic_id'], team_id=self.team['id']
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check if we're on the teams list page for a particular team."""
|
||||
self.wait_for_ajax()
|
||||
if self.team:
|
||||
if not self.url.endswith(self.url_path):
|
||||
return False
|
||||
return self.q(css='.teams-main .team-members').visible
|
||||
|
||||
@property
|
||||
def discussion_id(self):
|
||||
"""Get the id of the discussion module on the page"""
|
||||
return self.q(css='div.discussion-module').attrs('data-discussion-id')[0]
|
||||
|
||||
@property
|
||||
def discussion_page(self):
|
||||
"""Get the discussion as a bok_choy page object"""
|
||||
if not hasattr(self, '_discussion_page'):
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self._discussion_page = InlineDiscussionPage(self.browser, self.discussion_id)
|
||||
return self._discussion_page
|
||||
|
||||
@property
|
||||
def team_name(self):
|
||||
"""Get the team's name as displayed in the page header"""
|
||||
return self.q(css='.page-header .page-title')[0].text
|
||||
|
||||
@property
|
||||
def team_description(self):
|
||||
"""Get the team's description as displayed in the page header"""
|
||||
return self.q(css=TEAMS_HEADER_CSS + ' .page-description')[0].text
|
||||
|
||||
@property
|
||||
def team_members_present(self):
|
||||
"""Verifies that team members are present"""
|
||||
return self.q(css='.page-content-secondary .team-members .team-member').present
|
||||
|
||||
@property
|
||||
def team_capacity_text(self):
|
||||
"""Returns team capacity text"""
|
||||
return self.q(css='.page-content-secondary .team-capacity :last-child').text[0]
|
||||
|
||||
@property
|
||||
def team_location(self):
|
||||
""" Returns team location/country. """
|
||||
return self.q(css='.page-content-secondary .team-country :last-child').text[0]
|
||||
|
||||
@property
|
||||
def team_language(self):
|
||||
""" Returns team location/country. """
|
||||
return self.q(css='.page-content-secondary .team-language :last-child').text[0]
|
||||
|
||||
@property
|
||||
def team_user_membership_text(self):
|
||||
"""Returns the team membership text"""
|
||||
query = self.q(css='.page-content-secondary > .team-user-membership-status')
|
||||
return query.text[0] if query.present else ''
|
||||
|
||||
@property
|
||||
def team_leave_link_present(self):
|
||||
"""Verifies that team leave link is present"""
|
||||
return self.q(css='.leave-team-link').present
|
||||
|
||||
def click_leave_team_link(self, remaining_members=0, cancel=False):
|
||||
""" Click on Leave Team link"""
|
||||
leave_team_css = '.leave-team-link'
|
||||
self.scroll_to_element(leave_team_css)
|
||||
self.wait_for_element_visibility(leave_team_css, 'Leave Team link is visible.')
|
||||
click_css(self, leave_team_css, require_notification=False)
|
||||
confirm_prompt(self, cancel, require_notification=False)
|
||||
|
||||
if cancel is False:
|
||||
self.wait_for(
|
||||
lambda: self.join_team_button_present,
|
||||
description="Join Team button did not become present"
|
||||
)
|
||||
self.wait_for_capacity_text(remaining_members)
|
||||
|
||||
@property
|
||||
def team_members(self):
|
||||
"""Returns the number of team members in this team"""
|
||||
return len(self.q(css='.page-content-secondary .team-member'))
|
||||
|
||||
def click_first_profile_image(self):
|
||||
"""Clicks on first team member's profile image"""
|
||||
self.q(css='.page-content-secondary .members-info .team-member').first.click()
|
||||
|
||||
@property
|
||||
def first_member_username(self):
|
||||
"""Returns the username of team member"""
|
||||
return self.q(css='.page-content-secondary .tooltip-custom').text[0]
|
||||
|
||||
def click_join_team_button(self, total_members=1):
|
||||
""" Click on Join Team button"""
|
||||
self.q(css='.join-team .action-primary').first.click()
|
||||
self.wait_for(
|
||||
lambda: not self.join_team_button_present,
|
||||
description="Join Team button did not go away"
|
||||
)
|
||||
self.wait_for_capacity_text(total_members)
|
||||
|
||||
def wait_for_capacity_text(self, num_members, max_size=10):
|
||||
""" Wait for the team capacity text to be correct. """
|
||||
self.wait_for(
|
||||
lambda: self.team_capacity_text == self.format_capacity_text(num_members, max_size),
|
||||
description="Team capacity text is not correct"
|
||||
)
|
||||
|
||||
def format_capacity_text(self, num_members, max_size):
|
||||
""" Helper method to format the expected team capacity text. """
|
||||
return u'{num_members} / {max_size} {members_text}'.format(
|
||||
num_members=num_members,
|
||||
max_size=max_size,
|
||||
members_text='Member' if num_members == max_size else 'Members'
|
||||
)
|
||||
|
||||
@property
|
||||
def join_team_message(self):
|
||||
""" Returns join team message """
|
||||
self.wait_for_ajax()
|
||||
return self.q(css='.join-team .join-team-message').text[0]
|
||||
|
||||
@property
|
||||
def join_team_button_present(self):
|
||||
""" Returns True if Join Team button is present else False """
|
||||
return self.q(css='.join-team .action-primary').present
|
||||
|
||||
@property
|
||||
def join_team_message_present(self):
|
||||
""" Returns True if Join Team message is present else False """
|
||||
return self.q(css='.join-team .join-team-message').present
|
||||
|
||||
@property
|
||||
def new_post_button_present(self):
|
||||
""" Returns True if New Post button is present else False """
|
||||
return self.q(css='.discussion-module .new-post-btn').visible
|
||||
|
||||
@property
|
||||
def edit_team_button_present(self):
|
||||
""" Returns True if Edit Team button is present else False """
|
||||
return self.q(css='.form-actions .action-edit-team').present
|
||||
|
||||
def click_edit_team_button(self):
|
||||
""" Click on Edit Team button"""
|
||||
self.q(css='.form-actions .action-edit-team').first.click()
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Track selection page"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
from common.test.acceptance.pages.lms.course_home import CourseHomePage
|
||||
from common.test.acceptance.pages.lms.pay_and_verify import PaymentAndVerificationFlow
|
||||
|
||||
|
||||
class TrackSelectionPage(PageObject):
|
||||
"""Interact with the track selection page.
|
||||
|
||||
This page can be accessed at `/course_modes/choose/{course_id}/`.
|
||||
"""
|
||||
def __init__(self, browser, course_id):
|
||||
"""Initialize the page.
|
||||
|
||||
Arguments:
|
||||
browser (Browser): The browser instance.
|
||||
course_id (unicode): The course in which the user is enrolling.
|
||||
"""
|
||||
super(TrackSelectionPage, self).__init__(browser)
|
||||
self._course_id = course_id
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""Return the URL corresponding to the track selection page."""
|
||||
url = "{base}/course_modes/choose/{course_id}/".format(
|
||||
base=BASE_URL,
|
||||
course_id=self._course_id
|
||||
)
|
||||
|
||||
return url
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check if the track selection page has loaded."""
|
||||
return self.q(css=".wrapper-register-choose").is_present()
|
||||
|
||||
def enroll(self, mode="audit"):
|
||||
"""Interact with one of the enrollment buttons on the page.
|
||||
|
||||
Keyword Arguments:
|
||||
mode (str): Can be "audit" or "verified"
|
||||
|
||||
Raises:
|
||||
ValueError
|
||||
"""
|
||||
if mode == "verified":
|
||||
# Check the first contribution option, then click the enroll button
|
||||
self.q(css=".contribution-option > input").first.click()
|
||||
self.q(css="button[name='verified_mode']").click()
|
||||
return PaymentAndVerificationFlow(self.browser, self._course_id).wait_for_page()
|
||||
|
||||
elif mode == "audit":
|
||||
self.q(css="input[name='audit_mode']").click()
|
||||
return CourseHomePage(self.browser, self._course_id).wait_for_page()
|
||||
|
||||
else:
|
||||
raise ValueError("Mode must be either 'audit' or 'verified'.")
|
||||
@@ -3,16 +3,11 @@ Video player in the courseware.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
from bok_choy.javascript import js_defined, wait_for_js
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise, Promise
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from six.moves import zip
|
||||
|
||||
log = logging.getLogger('VideoPage')
|
||||
|
||||
@@ -94,18 +89,6 @@ class VideoPage(PageObject):
|
||||
video_selector = '{0}'.format(CSS_CLASS_NAMES['video_container'])
|
||||
self.wait_for_element_presence(video_selector, 'Video is initialized')
|
||||
|
||||
def scroll_to_button(self, button_name, index=0):
|
||||
"""
|
||||
Scroll to a button specified by `button_name`
|
||||
|
||||
Arguments:
|
||||
button_name (str): button name
|
||||
index (int): query index
|
||||
|
||||
"""
|
||||
element = self.q(css=VIDEO_BUTTONS[button_name])[index]
|
||||
self.browser.execute_script("arguments[0].scrollIntoView();", element)
|
||||
|
||||
@wait_for_js
|
||||
def wait_for_video_player_render(self, autoplay=False):
|
||||
"""
|
||||
@@ -139,34 +122,6 @@ class VideoPage(PageObject):
|
||||
|
||||
self.wait_for_ajax()
|
||||
|
||||
@wait_for_js
|
||||
def wait_for_video_bumper_render(self):
|
||||
"""
|
||||
Wait until Poster, Video Pre-Roll and main Video Player are Rendered Completely.
|
||||
"""
|
||||
self.wait_for_video_class()
|
||||
self.wait_for_element_presence(CSS_CLASS_NAMES['video_init'], 'Video Player Initialized')
|
||||
self.wait_for_element_presence(CSS_CLASS_NAMES['video_time'], 'Video Player Initialized')
|
||||
|
||||
video_player_buttons = ['do_not_show_again', 'skip_bumper', 'volume']
|
||||
for button in video_player_buttons:
|
||||
self.wait_for_element_visibility(VIDEO_BUTTONS[button], u'{} button is visible'.format(button))
|
||||
|
||||
@property
|
||||
def is_poster_shown(self):
|
||||
"""
|
||||
Check whether a poster is show.
|
||||
"""
|
||||
selector = self.get_element_selector(CSS_CLASS_NAMES['poster'])
|
||||
return self.q(css=selector).visible
|
||||
|
||||
def click_on_poster(self):
|
||||
"""
|
||||
Click on the video poster.
|
||||
"""
|
||||
selector = self.get_element_selector(CSS_CLASS_NAMES['poster'])
|
||||
self.q(css=selector).click()
|
||||
|
||||
def get_video_vertical_selector(self, video_display_name=None):
|
||||
"""
|
||||
Get selector for a video vertical with display name specified by `video_display_name`.
|
||||
@@ -215,96 +170,6 @@ class VideoPage(PageObject):
|
||||
"""
|
||||
self.current_video_display_name = video_display_name
|
||||
|
||||
def is_video_rendered(self, mode):
|
||||
"""
|
||||
Check that if video is rendered in `mode`.
|
||||
|
||||
Arguments:
|
||||
mode (str): Video mode, one of `html5`, `youtube`, `hls`.
|
||||
|
||||
Returns:
|
||||
bool: Tells if video is rendered in `mode`.
|
||||
|
||||
"""
|
||||
selector = self.get_element_selector(VIDEO_MODES[mode])
|
||||
|
||||
def _is_element_present():
|
||||
"""
|
||||
Check if a web element is present in DOM.
|
||||
|
||||
Returns:
|
||||
tuple: (is_satisfied, result)`, where `is_satisfied` is a boolean indicating whether the promise was
|
||||
satisfied, and `result` is a value to return from the fulfilled `Promise`.
|
||||
|
||||
"""
|
||||
is_present = self.q(css=selector).present
|
||||
# There is no way to get actual HLS video URL. Becuase in hls video
|
||||
# src attribute is not set to original url. https://github.com/video-dev/hls.js/issues/1052
|
||||
# http://www.streambox.fr/playlists/x36xhzz/x36xhzz.m3u8 becomes
|
||||
# "blob:https://studio-hlsvideo.sandbox.edx.org/0e2e72e0-904e-d946-9ce0-06c542894cda"
|
||||
if mode == 'hls':
|
||||
href_src = self.q(css=selector).attrs('src')[0]
|
||||
is_present = href_src.startswith('blob:') or href_src.startswith('mediasource:')
|
||||
return is_present, is_present
|
||||
|
||||
return Promise(_is_element_present, u'Video Rendering Failed in {0} mode.'.format(mode)).fulfill()
|
||||
|
||||
@property
|
||||
def video_download_url(self):
|
||||
"""
|
||||
Return video download url or None
|
||||
"""
|
||||
browser_query = self.q(css='.wrapper-download-video .btn-link.video-sources')
|
||||
return browser_query.attrs('href')[0] if browser_query.visible else None
|
||||
|
||||
@property
|
||||
def is_autoplay_enabled(self):
|
||||
"""
|
||||
Extract autoplay value of `data-metadata` attribute to check video autoplay is enabled or disabled.
|
||||
|
||||
Returns:
|
||||
bool: Tells if autoplay enabled/disabled.
|
||||
"""
|
||||
selector = self.get_element_selector(CSS_CLASS_NAMES['video_container'])
|
||||
auto_play = json.loads(self.q(css=selector).attrs('data-metadata')[0])['autoplay']
|
||||
return auto_play
|
||||
|
||||
@property
|
||||
def is_error_message_shown(self):
|
||||
"""
|
||||
Checks if video player error message shown.
|
||||
|
||||
Returns:
|
||||
bool: Tells about error message visibility.
|
||||
|
||||
"""
|
||||
selector = self.get_element_selector(CSS_CLASS_NAMES['error_message'])
|
||||
return self.q(css=selector).visible
|
||||
|
||||
@property
|
||||
def is_spinner_shown(self):
|
||||
"""
|
||||
Checks if video spinner shown.
|
||||
|
||||
Returns:
|
||||
bool: Tells about spinner visibility.
|
||||
|
||||
"""
|
||||
selector = self.get_element_selector(CSS_CLASS_NAMES['video_spinner'])
|
||||
return self.q(css=selector).visible
|
||||
|
||||
@property
|
||||
def error_message_text(self):
|
||||
"""
|
||||
Extract video player error message text.
|
||||
|
||||
Returns:
|
||||
str: Error message text.
|
||||
|
||||
"""
|
||||
selector = self.get_element_selector(CSS_CLASS_NAMES['error_message'])
|
||||
return self.q(css=selector).text[0]
|
||||
|
||||
def is_button_shown(self, button_id):
|
||||
"""
|
||||
Check if a video button specified by `button_id` is visible.
|
||||
@@ -325,24 +190,6 @@ class VideoPage(PageObject):
|
||||
"""
|
||||
self._captions_visibility(True)
|
||||
|
||||
def hide_captions(self):
|
||||
"""
|
||||
Make Captions Invisible.
|
||||
"""
|
||||
self._captions_visibility(False)
|
||||
|
||||
def show_closed_captions(self):
|
||||
"""
|
||||
Make closed captions visible.
|
||||
"""
|
||||
self._closed_captions_visibility(True)
|
||||
|
||||
def hide_closed_captions(self):
|
||||
"""
|
||||
Make closed captions invisible.
|
||||
"""
|
||||
self._closed_captions_visibility(False)
|
||||
|
||||
def is_captions_visible(self):
|
||||
"""
|
||||
Get current visibility sate of captions.
|
||||
@@ -355,18 +202,6 @@ class VideoPage(PageObject):
|
||||
caption_state_selector = self.get_element_selector(CSS_CLASS_NAMES['captions'])
|
||||
return self.q(css=caption_state_selector).visible
|
||||
|
||||
def is_closed_captions_visible(self):
|
||||
"""
|
||||
Get current visibility sate of closed captions.
|
||||
|
||||
Returns:
|
||||
bool: True means captions are visible, False means captions are not visible
|
||||
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
closed_caption_state_selector = self.get_element_selector(CSS_CLASS_NAMES['closed_captions'])
|
||||
return self.q(css=closed_caption_state_selector).visible
|
||||
|
||||
@wait_for_js
|
||||
def _captions_visibility(self, captions_new_state):
|
||||
"""
|
||||
@@ -390,577 +225,3 @@ class VideoPage(PageObject):
|
||||
# Verify that captions state is toggled/changed
|
||||
EmptyPromise(lambda: self.is_captions_visible() == captions_new_state,
|
||||
u"Transcripts are {state}".format(state=state)).fulfill()
|
||||
|
||||
@wait_for_js
|
||||
def _closed_captions_visibility(self, closed_captions_new_state):
|
||||
"""
|
||||
Set the video closed captioning visibility state.
|
||||
|
||||
Arguments:
|
||||
closed_captions_new_state (bool): True means show closed captioning
|
||||
"""
|
||||
states = {True: 'shown', False: 'hidden'}
|
||||
state = states[closed_captions_new_state]
|
||||
|
||||
self.click_player_button('cc_button')
|
||||
|
||||
# Make sure that the captions are visible
|
||||
EmptyPromise(lambda: self.is_closed_captions_visible() == closed_captions_new_state,
|
||||
u"Closed captions are {state}".format(state=state)).fulfill()
|
||||
|
||||
@property
|
||||
def captions_text(self):
|
||||
"""
|
||||
Extract captions text.
|
||||
|
||||
Returns:
|
||||
str: Captions Text.
|
||||
|
||||
"""
|
||||
self.wait_for_captions()
|
||||
|
||||
captions_selector = self.get_element_selector(CSS_CLASS_NAMES['captions_text'])
|
||||
subs = self.q(css=captions_selector).html
|
||||
|
||||
return ' '.join(subs)
|
||||
|
||||
@property
|
||||
def closed_captions_text(self):
|
||||
"""
|
||||
Extract closed captioning text.
|
||||
|
||||
Returns:
|
||||
str: closed captions Text.
|
||||
|
||||
"""
|
||||
self.wait_for_closed_captions()
|
||||
|
||||
closed_captions_selector = self.get_element_selector(CSS_CLASS_NAMES['closed_captions'])
|
||||
subs = self.q(css=closed_captions_selector).html
|
||||
|
||||
return ' '.join(subs)
|
||||
|
||||
def click_transcript_line(self, line_no):
|
||||
"""
|
||||
Clicks a line in the transcript updating the current caption.
|
||||
|
||||
Arguments:
|
||||
line_no (int): line number to be clicked
|
||||
"""
|
||||
|
||||
self.wait_for_captions()
|
||||
captions_selector = self.q(css=CSS_CLASS_NAMES['captions_text_getter'].format(line_no))
|
||||
captions_selector.click()
|
||||
|
||||
@property
|
||||
def active_caption_text(self):
|
||||
"""
|
||||
Return active caption text.
|
||||
"""
|
||||
return self.q(css=CSS_CLASS_NAMES['active_caption_text']).text[0]
|
||||
|
||||
@property
|
||||
def speed(self):
|
||||
"""
|
||||
Get current video speed value.
|
||||
|
||||
Return:
|
||||
str: speed value
|
||||
|
||||
"""
|
||||
speed_selector = self.get_element_selector(CSS_CLASS_NAMES['video_speed'])
|
||||
return self.q(css=speed_selector).text[0]
|
||||
|
||||
@speed.setter
|
||||
def speed(self, speed):
|
||||
"""
|
||||
Change the video play speed.
|
||||
|
||||
Arguments:
|
||||
speed (str): Video speed value
|
||||
|
||||
"""
|
||||
# mouse over to video speed button
|
||||
self.scroll_to_button('speed')
|
||||
speed_menu_selector = self.get_element_selector(VIDEO_BUTTONS['speed'])
|
||||
element_to_hover_over = self.q(css=speed_menu_selector).results[0]
|
||||
hover = ActionChains(self.browser).move_to_element(element_to_hover_over)
|
||||
hover.perform()
|
||||
|
||||
speed_selector = self.get_element_selector(u'li[data-speed="{speed}"] .control'.format(speed=speed))
|
||||
self.q(css=speed_selector).first.click()
|
||||
# Click triggers an ajax event
|
||||
self.wait_for_ajax()
|
||||
|
||||
def verify_speed_changed(self, expected_speed):
|
||||
"""
|
||||
Wait for the video to change its speed to the expected value. If it does not change,
|
||||
the wait call will fail the test.
|
||||
"""
|
||||
self.wait_for(lambda: self.speed == expected_speed, "Video speed changed")
|
||||
|
||||
def click_player_button(self, button):
|
||||
"""
|
||||
Click on `button`.
|
||||
|
||||
Arguments:
|
||||
button (str): key in VIDEO_BUTTONS dictionary, its value will give us the css selector for `button`
|
||||
|
||||
"""
|
||||
button_selector = self.get_element_selector(VIDEO_BUTTONS[button])
|
||||
|
||||
# If we are going to click pause button, Ensure that player is not in buffering state
|
||||
if button == 'pause':
|
||||
self.wait_for(lambda: self.state != 'buffering', 'Player is Ready for Pause')
|
||||
|
||||
self.q(css=button_selector).first.click()
|
||||
|
||||
self.wait_for_ajax()
|
||||
|
||||
def _get_element_dimensions(self, selector):
|
||||
"""
|
||||
Gets the width and height of element specified by `selector`
|
||||
|
||||
Arguments:
|
||||
selector (str): css selector of a web element
|
||||
|
||||
Returns:
|
||||
dict: Dimensions of a web element.
|
||||
|
||||
"""
|
||||
element = self.q(css=selector).results[0]
|
||||
return element.size
|
||||
|
||||
@property
|
||||
def _dimensions(self):
|
||||
"""
|
||||
Gets the video player dimensions.
|
||||
|
||||
Returns:
|
||||
tuple: Dimensions
|
||||
|
||||
"""
|
||||
iframe_selector = self.get_element_selector('.video-player iframe,')
|
||||
video_selector = self.get_element_selector(' .video-player video')
|
||||
video = self._get_element_dimensions(iframe_selector + video_selector)
|
||||
wrapper = self._get_element_dimensions(self.get_element_selector('.tc-wrapper'))
|
||||
controls = self._get_element_dimensions(self.get_element_selector('.video-controls'))
|
||||
progress_slider = self._get_element_dimensions(
|
||||
self.get_element_selector('.video-controls > .slider'))
|
||||
|
||||
expected = dict(wrapper)
|
||||
expected['height'] -= controls['height'] + 0.5 * progress_slider['height']
|
||||
|
||||
return video, expected
|
||||
|
||||
def is_aligned(self, is_transcript_visible):
|
||||
"""
|
||||
Check if video is aligned properly.
|
||||
|
||||
Arguments:
|
||||
is_transcript_visible (bool): Transcript is visible or not.
|
||||
|
||||
Returns:
|
||||
bool: Alignment result.
|
||||
|
||||
"""
|
||||
# Width of the video container in css equal 75% of window if transcript enabled
|
||||
wrapper_width = 75 if is_transcript_visible else 100
|
||||
initial = self.browser.get_window_size()
|
||||
|
||||
self.browser.set_window_size(300, 600)
|
||||
|
||||
# Wait for browser to resize completely
|
||||
# Currently there is no other way to wait instead of explicit wait
|
||||
time.sleep(0.2)
|
||||
|
||||
real, expected = self._dimensions
|
||||
|
||||
width = round(100 * real['width'] / expected['width']) == wrapper_width
|
||||
|
||||
self.browser.set_window_size(600, 300)
|
||||
|
||||
# Wait for browser to resize completely
|
||||
# Currently there is no other way to wait instead of explicit wait
|
||||
time.sleep(0.2)
|
||||
|
||||
real, expected = self._dimensions
|
||||
|
||||
height = abs(expected['height'] - real['height']) <= 5
|
||||
|
||||
# Restore initial window size
|
||||
self.browser.set_window_size(
|
||||
initial['width'], initial['height']
|
||||
)
|
||||
|
||||
return all([width, height])
|
||||
|
||||
def _get_transcript(self, url):
|
||||
"""
|
||||
Download Transcript from `url`
|
||||
|
||||
"""
|
||||
kwargs = dict()
|
||||
|
||||
session_id = [{i['name']: i['value']} for i in self.browser.get_cookies() if i['name'] == u'sessionid']
|
||||
if session_id:
|
||||
kwargs.update({
|
||||
'cookies': session_id[0]
|
||||
})
|
||||
|
||||
response = requests.get(url, **kwargs)
|
||||
return response.status_code < 400, response.headers, response.content
|
||||
|
||||
def get_cookie(self, cookie_name):
|
||||
"""
|
||||
Searches for and returns `cookie_name`
|
||||
"""
|
||||
return self.browser.get_cookie(cookie_name)
|
||||
|
||||
def downloaded_transcript_contains_text(self, transcript_format, text_to_search):
|
||||
"""
|
||||
Download the transcript in format `transcript_format` and check that it contains the text `text_to_search`
|
||||
|
||||
Arguments:
|
||||
transcript_format (str): Transcript file format `srt` or `txt`
|
||||
text_to_search (str): Text to search in Transcript.
|
||||
|
||||
Returns:
|
||||
bool: Transcript download result.
|
||||
|
||||
"""
|
||||
transcript_selector = self.get_element_selector(VIDEO_MENUS['transcript-format'][transcript_format])
|
||||
|
||||
# check if we have a transcript with correct format
|
||||
if '.' + transcript_format not in self.q(css=transcript_selector).text[0]:
|
||||
return False
|
||||
|
||||
formats = {
|
||||
'srt': 'application/x-subrip',
|
||||
'txt': 'text/plain',
|
||||
}
|
||||
|
||||
link = self.q(css=transcript_selector)
|
||||
url = link.attrs('href')[0]
|
||||
link.click()
|
||||
|
||||
result, headers, content = self._get_transcript(url)
|
||||
|
||||
if result is False:
|
||||
return False
|
||||
|
||||
if text_to_search not in content.decode('utf-8'):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def current_language(self):
|
||||
"""
|
||||
Get current selected video transcript language.
|
||||
"""
|
||||
selector = self.get_element_selector(VIDEO_MENUS["language"] + ' li.is-active')
|
||||
return self.q(css=selector).first.attrs('data-lang-code')[0]
|
||||
|
||||
def select_language(self, code):
|
||||
"""
|
||||
Select captions for language `code`.
|
||||
|
||||
Arguments:
|
||||
code (str): two character language code like `en`, `zh`.
|
||||
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
|
||||
# TODO remove this sleep and wait for the right thing to finish rendering
|
||||
time.sleep(1)
|
||||
|
||||
# mouse over to transcript button
|
||||
cc_button_selector = self.get_element_selector(VIDEO_BUTTONS["transcript_button"])
|
||||
element_to_hover_over = self.q(css=cc_button_selector).results[0]
|
||||
ActionChains(self.browser).move_to_element(element_to_hover_over).perform()
|
||||
|
||||
language_selector = VIDEO_MENUS["language"] + u' li[data-lang-code="{code}"]'.format(code=code)
|
||||
language_selector = self.get_element_selector(language_selector)
|
||||
self.wait_for_element_visibility(language_selector, 'language menu is visible')
|
||||
hover_target = self.q(css=language_selector).results[0]
|
||||
ActionChains(self.browser).move_to_element(hover_target).perform()
|
||||
self.q(css=language_selector).first.click()
|
||||
|
||||
# Sometimes language is not clicked correctly. So, if the current language code
|
||||
# differs form the expected, we try to change it again.
|
||||
if self.current_language() != code:
|
||||
self.select_language(code)
|
||||
|
||||
if 'is-active' != self.q(css=language_selector).attrs('class')[0]:
|
||||
return False
|
||||
|
||||
active_lang_selector = self.get_element_selector(VIDEO_MENUS["language"] + ' li.is-active')
|
||||
if len(self.q(css=active_lang_selector).results) != 1:
|
||||
return False
|
||||
|
||||
# Make sure that all ajax requests that affects the display of captions are finished.
|
||||
# For example, request to get new translation etc.
|
||||
self.wait_for_ajax()
|
||||
|
||||
captions_selector = self.get_element_selector(CSS_CLASS_NAMES['captions'])
|
||||
EmptyPromise(lambda: self.q(css=captions_selector).visible, 'Subtitles Visible').fulfill()
|
||||
|
||||
self.wait_for_captions()
|
||||
|
||||
return True
|
||||
|
||||
def is_menu_present(self, menu_name):
|
||||
"""
|
||||
Check if menu `menu_name` exists.
|
||||
|
||||
Arguments:
|
||||
menu_name (str): Menu key from VIDEO_MENUS.
|
||||
|
||||
Returns:
|
||||
bool: Menu existence result
|
||||
|
||||
"""
|
||||
selector = self.get_element_selector(VIDEO_MENUS[menu_name])
|
||||
return self.q(css=selector).present
|
||||
|
||||
@property
|
||||
def sources(self):
|
||||
"""
|
||||
Extract all video source urls on current page.
|
||||
|
||||
Returns:
|
||||
list: Video Source URLs.
|
||||
|
||||
"""
|
||||
sources_selector = self.get_element_selector(CSS_CLASS_NAMES['video_sources'])
|
||||
return self.q(css=sources_selector).map(lambda el: el.get_attribute('src').split('?')[0]).results
|
||||
|
||||
@property
|
||||
def caption_languages(self):
|
||||
"""
|
||||
Get caption languages available for a video.
|
||||
|
||||
Returns:
|
||||
dict: Language Codes('en', 'zh' etc) as keys and Language Names as Values('English', 'Chinese' etc)
|
||||
|
||||
"""
|
||||
languages_selector = self.get_element_selector(CSS_CLASS_NAMES['captions_lang_list'])
|
||||
language_codes = self.q(css=languages_selector).attrs('data-lang-code')
|
||||
language_names = self.q(css=languages_selector).attrs('textContent')
|
||||
|
||||
return dict(list(zip(language_codes, language_names)))
|
||||
|
||||
@property
|
||||
def position(self):
|
||||
"""
|
||||
Gets current video slider position.
|
||||
|
||||
Returns:
|
||||
str: current seek position in format min:sec.
|
||||
|
||||
"""
|
||||
selector = self.get_element_selector(CSS_CLASS_NAMES['video_time'])
|
||||
current_seek_position = self.q(css=selector).text[0]
|
||||
return current_seek_position.split('/')[0].strip()
|
||||
|
||||
@property
|
||||
def seconds(self):
|
||||
"""
|
||||
Extract seconds part from current video slider position.
|
||||
|
||||
Returns:
|
||||
str
|
||||
|
||||
"""
|
||||
return int(self.position.split(':')[1])
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""
|
||||
Extract the current state (play, pause etc) of video.
|
||||
|
||||
Returns:
|
||||
str: current video state
|
||||
|
||||
"""
|
||||
state_selector = self.get_element_selector(CSS_CLASS_NAMES['video_container'])
|
||||
current_state = self.q(css=state_selector).attrs('class')[0]
|
||||
|
||||
# For troubleshooting purposes show what the current state is.
|
||||
# The debug statements will only be displayed in the event of a failure.
|
||||
logging.debug(u"Current state of '{}' element is '{}'".format(state_selector, current_state))
|
||||
|
||||
# See the JS video player's onStateChange function
|
||||
if 'is-playing' in current_state:
|
||||
return 'playing'
|
||||
elif 'is-paused' in current_state:
|
||||
return 'pause'
|
||||
elif 'is-buffered' in current_state:
|
||||
return 'buffering'
|
||||
elif 'is-ended' in current_state:
|
||||
return 'finished'
|
||||
|
||||
def _wait_for(self, check_func, desc, result=False, timeout=200, try_interval=0.2):
|
||||
"""
|
||||
Calls the method provided as an argument until the Promise satisfied or BrokenPromise
|
||||
|
||||
Arguments:
|
||||
check_func (callable): Function that accepts no arguments and returns a boolean indicating whether the promise is fulfilled.
|
||||
desc (str): Description of the Promise, used in log messages.
|
||||
result (bool): Indicates whether we need a results from Promise or not
|
||||
timeout (float): Maximum number of seconds to wait for the Promise to be satisfied before timing out.
|
||||
|
||||
"""
|
||||
if result:
|
||||
return Promise(check_func, desc, timeout=timeout, try_interval=try_interval).fulfill()
|
||||
else:
|
||||
return EmptyPromise(check_func, desc, timeout=timeout, try_interval=try_interval).fulfill()
|
||||
|
||||
def wait_for_state(self, state):
|
||||
"""
|
||||
Wait until `state` occurs.
|
||||
|
||||
Arguments:
|
||||
state (str): State we wait for.
|
||||
|
||||
"""
|
||||
self._wait_for(
|
||||
lambda: self.state == state,
|
||||
u'State is {state}'.format(state=state)
|
||||
)
|
||||
|
||||
def seek(self, seek_value):
|
||||
"""
|
||||
Seek the video to position specified by `seek_value`.
|
||||
|
||||
Arguments:
|
||||
seek_value (str): seek value
|
||||
|
||||
"""
|
||||
seek_time = _parse_time_str(seek_value)
|
||||
seek_selector = self.get_element_selector(' .video')
|
||||
js_code = u"$('{seek_selector}').data('video-player-state').videoPlayer.onSlideSeek({{time: {seek_time}}})".format(
|
||||
seek_selector=seek_selector, seek_time=seek_time)
|
||||
self.browser.execute_script(js_code)
|
||||
|
||||
# after seek, player goes into `is-buffered` state. we need to get
|
||||
# out of this state before doing any further operation/action.
|
||||
def _is_buffering_completed():
|
||||
"""
|
||||
Check if buffering completed
|
||||
"""
|
||||
return self.state != 'buffering'
|
||||
|
||||
self._wait_for(_is_buffering_completed, 'Buffering completed after Seek.')
|
||||
self.wait_for_position(seek_value)
|
||||
|
||||
def reload_page(self):
|
||||
"""
|
||||
Reload/Refresh the current video page.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
self.wait_for_video_player_render()
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
"""
|
||||
Extract video duration.
|
||||
|
||||
Returns:
|
||||
str: duration in format min:sec
|
||||
|
||||
"""
|
||||
selector = self.get_element_selector(CSS_CLASS_NAMES['video_time'])
|
||||
|
||||
# The full time has the form "0:32 / 3:14" elapsed/duration
|
||||
all_times = self.q(css=selector).text[0]
|
||||
|
||||
duration_str = all_times.split('/')[1]
|
||||
|
||||
return duration_str.strip()
|
||||
|
||||
def wait_for_position(self, position):
|
||||
"""
|
||||
Wait until current will be equal to `position`.
|
||||
|
||||
Arguments:
|
||||
position (str): position we wait for.
|
||||
|
||||
"""
|
||||
self._wait_for(
|
||||
lambda: self.position == position,
|
||||
u'Position is {position}'.format(position=position)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_quality_button_visible(self):
|
||||
"""
|
||||
Get the visibility state of quality button
|
||||
|
||||
Returns:
|
||||
bool: visibility status
|
||||
|
||||
"""
|
||||
selector = self.get_element_selector(VIDEO_BUTTONS['quality'])
|
||||
return self.q(css=selector).visible
|
||||
|
||||
@property
|
||||
def is_quality_button_active(self):
|
||||
"""
|
||||
Check if quality button is active or not.
|
||||
|
||||
Returns:
|
||||
bool: active status
|
||||
|
||||
"""
|
||||
selector = self.get_element_selector(VIDEO_BUTTONS['quality'])
|
||||
|
||||
classes = self.q(css=selector).attrs('class')[0].split()
|
||||
return 'active' in classes
|
||||
|
||||
@property
|
||||
def is_transcript_skip_visible(self):
|
||||
"""
|
||||
Checks if the skip-to containers in transcripts are present and visible.
|
||||
|
||||
Returns:
|
||||
bool
|
||||
"""
|
||||
selector = self.get_element_selector(VIDEO_MENUS['transcript-skip'])
|
||||
return self.q(css=selector).visible
|
||||
|
||||
def wait_for_captions(self):
|
||||
"""
|
||||
Wait until captions rendered completely.
|
||||
"""
|
||||
captions_rendered_selector = self.get_element_selector(CSS_CLASS_NAMES['captions_rendered'])
|
||||
self.wait_for_element_visibility(captions_rendered_selector, 'Captions Rendered')
|
||||
|
||||
def wait_for_closed_captions(self):
|
||||
"""
|
||||
Wait until closed captions are rendered completely.
|
||||
"""
|
||||
cc_rendered_selector = self.get_element_selector(CSS_CLASS_NAMES['closed_captions'])
|
||||
self.wait_for_element_visibility(cc_rendered_selector, 'Closed captions rendered')
|
||||
|
||||
def wait_for_closed_captions_to_be_hidden(self):
|
||||
"""
|
||||
Waits for the closed captions to be turned off completely.
|
||||
"""
|
||||
cc_rendered_selector = self.get_element_selector(CSS_CLASS_NAMES['closed_captions'])
|
||||
self.wait_for_element_invisibility(cc_rendered_selector, 'Closed captions hidden')
|
||||
|
||||
|
||||
def _parse_time_str(time_str):
|
||||
"""
|
||||
Parse a string of the form 1:23 into seconds (int).
|
||||
|
||||
Arguments:
|
||||
time_str (str): seek value
|
||||
|
||||
Returns:
|
||||
int: seek value in seconds
|
||||
|
||||
"""
|
||||
time_obj = time.strptime(time_str, '%M:%S')
|
||||
return time_obj.tm_min * 60 + time_obj.tm_sec
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
"""
|
||||
The Files and Uploads page for a course in Studio
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
|
||||
import six
|
||||
from bok_choy.javascript import wait_for_js
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from path import Path
|
||||
from six.moves import zip
|
||||
|
||||
from common.test.acceptance.pages.common.utils import sync_on_notification
|
||||
from common.test.acceptance.pages.studio import BASE_URL
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
|
||||
# file path found from CourseFixture logic
|
||||
UPLOAD_SUFFIX = '/data/uploads/studio-uploads/'
|
||||
UPLOAD_FILE_DIR = Path(__file__).abspath().dirname().dirname().dirname().dirname() + UPLOAD_SUFFIX
|
||||
|
||||
|
||||
class AssetIndexPageStudioFrontend(CoursePage):
|
||||
"""The Files and Uploads page for a course in Studio"""
|
||||
|
||||
PAGINATION_PAGE_ELEMENT = ".pagination .page-item"
|
||||
TABLE_SORT_BUTTONS = 'th.sortable button.btn-header'
|
||||
TYPE_FILTER_ELEMENT = 'div[data-identifier="asset-filters"] .form-group'
|
||||
URL_PATH = "assets"
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""Construct a URL to the page within the course."""
|
||||
# TODO - is there a better way to make this agnostic to the underlying default module store?
|
||||
default_store = os.environ.get('DEFAULT_STORE', 'draft')
|
||||
course_key = CourseLocator(
|
||||
self.course_info['course_org'],
|
||||
self.course_info['course_num'],
|
||||
self.course_info['course_run'],
|
||||
deprecated=(default_store == 'draft')
|
||||
)
|
||||
url = "/".join([BASE_URL, self.URL_PATH, six.moves.urllib.parse.quote_plus(six.text_type(course_key))])
|
||||
return url if url[-1] == '/' else url + '/'
|
||||
|
||||
@wait_for_js
|
||||
def is_browser_on_page(self):
|
||||
return all([
|
||||
self.q(css='body.view-uploads').present,
|
||||
self.q(css='.page-header').present,
|
||||
self.q(css='#root').present,
|
||||
not self.q(css='div.ui-loading').visible,
|
||||
])
|
||||
|
||||
@wait_for_js
|
||||
def is_studio_frontend_container_on_page(self):
|
||||
"""Checks that the studio-frontend container has been loaded."""
|
||||
return self.q(css='.SFE').present
|
||||
|
||||
@wait_for_js
|
||||
def is_table_element_on_page(self):
|
||||
"""Checks that table is on the page."""
|
||||
return self.q(css='table.table-responsive').present
|
||||
|
||||
@wait_for_js
|
||||
def is_upload_element_on_page(self):
|
||||
"""Checks that the dropzone area is on the page."""
|
||||
return self.q(css='.drop-zone').present
|
||||
|
||||
@wait_for_js
|
||||
def is_filter_element_on_page(self):
|
||||
"""Checks that type filter heading and checkboxes are on the page."""
|
||||
return all([
|
||||
self.q(css='.filter-heading').is_present,
|
||||
self.q(css=self.TYPE_FILTER_ELEMENT).present,
|
||||
])
|
||||
|
||||
@wait_for_js
|
||||
def is_pagination_element_on_page(self):
|
||||
"""Checks that pagination is on the page."""
|
||||
return self.q(css='.pagination').present
|
||||
|
||||
@wait_for_js
|
||||
def is_search_element_on_page(self):
|
||||
"""Checks that search bar is on the page."""
|
||||
return self.q(css="[name='search']").present
|
||||
|
||||
@wait_for_js
|
||||
def is_status_alert_element_on_page(self):
|
||||
"""Checks that status alert is hidden on page."""
|
||||
return all([
|
||||
self.q(css='.alert').present,
|
||||
not self.q(css='.alert').visible,
|
||||
])
|
||||
|
||||
@wait_for_js
|
||||
def are_no_results_headings_on_page(self):
|
||||
"""Checks that no results page text is on page."""
|
||||
return self.q(css='.SFE-wrapper h3').filter(lambda el: el.text == '0 files found').present
|
||||
|
||||
@wait_for_js
|
||||
def is_no_results_clear_filter_button_on_page(self):
|
||||
"""Checks that no results clear filter button is on page."""
|
||||
return self.q(css='.SFE-wrapper button.btn').filter(
|
||||
lambda el: el.text == 'Clear all filters'
|
||||
).present
|
||||
|
||||
@property
|
||||
@wait_for_js
|
||||
def asset_files_names(self):
|
||||
"""
|
||||
Get the names of uploaded files.
|
||||
Returns:
|
||||
list: Names of files on current page.
|
||||
"""
|
||||
return self.q(css='span[data-identifier="asset-file-name"]').text
|
||||
|
||||
@property
|
||||
@wait_for_js
|
||||
def asset_files_types(self):
|
||||
"""
|
||||
Get the file types of uploaded files.
|
||||
Returns:
|
||||
list: File types of files on current page.
|
||||
"""
|
||||
return self.q(css='span[data-identifier="asset-content-type"]').text
|
||||
|
||||
@property
|
||||
@wait_for_js
|
||||
def number_of_asset_files(self):
|
||||
"""
|
||||
Returns the number of files on the current page.
|
||||
"""
|
||||
return len(self.q(css='span[data-identifier="asset-file-name"]').execute())
|
||||
|
||||
@property
|
||||
@wait_for_js
|
||||
def number_of_filters(self):
|
||||
return len(self.q(css='.form-check').execute())
|
||||
|
||||
@property
|
||||
@wait_for_js
|
||||
def number_of_sortable_buttons_in_table_heading(self):
|
||||
return len(self.q(css=self.TABLE_SORT_BUTTONS).execute())
|
||||
|
||||
@property
|
||||
@wait_for_js
|
||||
def asset_delete_buttons(self):
|
||||
"""Return a list of WebElements for deleting the assets"""
|
||||
css = 'button[data-identifier="asset-delete-button"]'
|
||||
return self.q(css=css).execute()
|
||||
|
||||
@wait_for_js
|
||||
def asset_lock_buttons(self, locked_only=True):
|
||||
"""
|
||||
Return a list of WebElements of the lock buttons for assets
|
||||
or an empty list if there are none.
|
||||
"""
|
||||
css = 'button[data-identifier="asset-lock-button"]'
|
||||
if locked_only:
|
||||
css = '{}.{}'.format(css, 'fa-lock')
|
||||
return self.q(css=css).execute()
|
||||
|
||||
@wait_for_js
|
||||
def select_type_filter(self, filter_number):
|
||||
"""
|
||||
Selects Images Type filter checkbox which filters the results.
|
||||
Returns False if no filter.
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
if self.is_filter_element_on_page():
|
||||
self.q(css=self.TYPE_FILTER_ELEMENT + ' .form-check .form-check-input').nth(filter_number).click()
|
||||
self.wait_for_ajax()
|
||||
return True
|
||||
return False
|
||||
|
||||
@wait_for_js
|
||||
def click_clear_filters_button(self):
|
||||
"""
|
||||
Clicks 'Clear all filters' button.
|
||||
Returns False if no 'Clear all filters' button.
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
if self.is_no_results_clear_filter_button_on_page():
|
||||
self.q(css='.SFE-wrapper button.btn').filter(
|
||||
lambda el: el.text == 'Clear all filters'
|
||||
).click()
|
||||
self.wait_for_ajax()
|
||||
return True
|
||||
return False
|
||||
|
||||
@wait_for_js
|
||||
def set_asset_lock(self, index=0):
|
||||
"""
|
||||
Set the state of the asset in the row specified by index
|
||||
to locked or unlocked by clicking the button.
|
||||
Note: this will raise an IndexError if the row does not exist.
|
||||
"""
|
||||
lock_button = self.q(css=".table-responsive tbody tr td:nth-child(7) button").execute()[index]
|
||||
lock_button.click()
|
||||
# Click initiates an ajax call, waiting for it to complete
|
||||
self.wait_for_ajax()
|
||||
sync_on_notification(self)
|
||||
|
||||
@wait_for_js
|
||||
def confirm_asset_deletion(self):
|
||||
""" Click to confirm deletion and sync on the notification."""
|
||||
confirmation_title_selector = '.modal'
|
||||
self.q(css=confirmation_title_selector + ' button[data-identifier="asset-confirm-delete-button"]').click()
|
||||
# Click initiates an ajax call, waiting for it to complete
|
||||
self.wait_for_ajax()
|
||||
sync_on_notification(self)
|
||||
|
||||
@wait_for_js
|
||||
def delete_first_asset(self):
|
||||
""" Deletes file then clicks delete on confirmation."""
|
||||
self.q(css='.fa-trash').first.click()
|
||||
self.confirm_asset_deletion()
|
||||
|
||||
@wait_for_js
|
||||
def delete_asset_named(self, name):
|
||||
"""Delete the asset with the specified name."""
|
||||
names = self.asset_files_names
|
||||
if name not in names:
|
||||
raise LookupError(u'Asset with filename {} not found.'.format(name))
|
||||
delete_buttons = self.asset_delete_buttons
|
||||
assets = dict(list(zip(names, delete_buttons)))
|
||||
# Now click the link in that row
|
||||
assets.get(name).click()
|
||||
self.confirm_asset_deletion()
|
||||
|
||||
@wait_for_js
|
||||
def delete_all_assets(self):
|
||||
"""Delete all uploaded assets."""
|
||||
while self.number_of_asset_files:
|
||||
self.delete_first_asset()
|
||||
|
||||
self.wait_for_ajax()
|
||||
self.wait_for_page()
|
||||
|
||||
@wait_for_js
|
||||
def upload_new_files(self, file_names):
|
||||
"""
|
||||
Upload file(s).
|
||||
|
||||
Arguments:
|
||||
file_names (list): file name(s) we want to upload.
|
||||
"""
|
||||
file_input_css = 'input[type=file]'
|
||||
|
||||
for file_name in file_names:
|
||||
# Make file input field visible.
|
||||
self.browser.execute_script('$("{}").css("display","block");'.format(file_input_css))
|
||||
self.wait_for_element_visibility(file_input_css, "Input is visible")
|
||||
# Send file to upload
|
||||
self.q(css=file_input_css).results[0].send_keys(
|
||||
UPLOAD_FILE_DIR + file_name)
|
||||
self.q(css=file_input_css).results[0].clear()
|
||||
# Wait for status alert and close
|
||||
self.wait_for_element_visibility(
|
||||
'.alert', 'Upload status alert is visible.')
|
||||
self.q(css='.close').first.click()
|
||||
|
||||
self.wait_for_ajax()
|
||||
self.wait_for_files_upload(len(file_names))
|
||||
|
||||
@wait_for_js
|
||||
def wait_for_files_upload(self, number):
|
||||
"""
|
||||
Wait for file(s) to upload.
|
||||
|
||||
Arguments:
|
||||
number (int): number of uploaded files.
|
||||
"""
|
||||
return EmptyPromise(
|
||||
lambda: self.number_of_asset_files == number,
|
||||
"Files finished uploading"
|
||||
).fulfill()
|
||||
|
||||
@property
|
||||
@wait_for_js
|
||||
def is_previous_button_enabled(self):
|
||||
return 'disabled' not in self.q(css=self.PAGINATION_PAGE_ELEMENT).first.attrs('class')[0]
|
||||
|
||||
@property
|
||||
@wait_for_js
|
||||
def is_next_button_enabled(self):
|
||||
return 'disabled' not in self.q(css=self.PAGINATION_PAGE_ELEMENT).nth(
|
||||
self.number_of_pagination_buttons - 1).attrs('class')[0]
|
||||
|
||||
@property
|
||||
@wait_for_js
|
||||
def is_previous_button_on_page(self):
|
||||
"""Note: the two conditions cover when the button is and is not disabled."""
|
||||
return 'previous' in self.q(css=self.PAGINATION_PAGE_ELEMENT + ' .previous').text
|
||||
|
||||
@property
|
||||
@wait_for_js
|
||||
def is_next_button_on_page(self):
|
||||
"""Note: the two conditions cover when the button is and is not disabled."""
|
||||
return 'next' in self.q(css=self.PAGINATION_PAGE_ELEMENT + ' .next').text
|
||||
|
||||
@wait_for_js
|
||||
def click_pagination_page_button(self, index):
|
||||
"""
|
||||
Click pagination page button.
|
||||
Return False if no pagination page button at specified index.
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
if index <= self.number_of_pagination_buttons:
|
||||
self.q(css=self.PAGINATION_PAGE_ELEMENT + ' .page-link').nth(index)[0].click()
|
||||
self.wait_for_ajax()
|
||||
return True
|
||||
return False
|
||||
|
||||
@wait_for_js
|
||||
def click_pagination_next_button(self):
|
||||
"""
|
||||
Click pagination next button.
|
||||
Return False if next button disabled.
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
if self.is_next_button_enabled:
|
||||
self.q(css=self.PAGINATION_PAGE_ELEMENT + ' .next.page-link')[0].click()
|
||||
self.wait_for_ajax()
|
||||
return True
|
||||
return False
|
||||
|
||||
@wait_for_js
|
||||
def click_pagination_previous_button(self):
|
||||
"""
|
||||
Click pagination previous button.
|
||||
Return False if previous button disabled.
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
if self.is_previous_button_enabled:
|
||||
self.q(css=self.PAGINATION_PAGE_ELEMENT + ' .previous.page-link')[0].click()
|
||||
self.wait_for_ajax()
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
@wait_for_js
|
||||
def number_of_pagination_buttons(self):
|
||||
"""Return the number of total pagination page buttons, including previous, pages, and next buttons."""
|
||||
return len(self.q(css=self.PAGINATION_PAGE_ELEMENT + ' .page-link'))
|
||||
|
||||
@wait_for_js
|
||||
def is_selected_page(self, index):
|
||||
"""
|
||||
Return true if the pagination page at the current index is selected.
|
||||
Return false if the pagination page at the current index does not exist
|
||||
or is not selected.
|
||||
|
||||
Note: this *does* include the 'previous' and 'next' buttons
|
||||
Note: 0-indexed
|
||||
"""
|
||||
if index < self.number_of_pagination_buttons:
|
||||
return 'active' in self.q(css=self.PAGINATION_PAGE_ELEMENT).nth(index)[0].get_attribute('class')
|
||||
return False
|
||||
|
||||
@wait_for_js
|
||||
def click_sort_button(self, button_text):
|
||||
"""
|
||||
Click sort button with the specified button text.
|
||||
|
||||
Arguments:
|
||||
button_text (string): text of the sort button to click.
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
sort_button = self.q(css=self.TABLE_SORT_BUTTONS).filter(
|
||||
lambda el: button_text in el.text
|
||||
)
|
||||
|
||||
if sort_button:
|
||||
sort_button.click()
|
||||
return True
|
||||
return False
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
Course Checklists page.
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
|
||||
|
||||
class CourseChecklistsPage(CoursePage):
|
||||
"""
|
||||
Course Checklists page.
|
||||
"""
|
||||
|
||||
url_path = "checklists"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
# SFE and SFE-wrapper classes come from studio-frontend and
|
||||
# wrap content provided by the studio-frontend package
|
||||
return self.q(css='.SFE .SFE-wrapper').visible
|
||||
@@ -1,145 +0,0 @@
|
||||
"""
|
||||
Course Updates page.
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css, confirm_prompt
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
from common.test.acceptance.pages.studio.utils import set_input_value, type_in_codemirror
|
||||
|
||||
|
||||
class CourseUpdatesPage(CoursePage):
|
||||
"""
|
||||
Course Updates page.
|
||||
"""
|
||||
url_path = "course_info"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Returns whether or not the browser on the page and has loaded the required content
|
||||
"""
|
||||
# Check for the presence of handouts-content, when it is present the render function has completed
|
||||
# loading the updates and handout sections
|
||||
return (self.q(css='.handouts-content').present and
|
||||
self.q(css='article#course-update-view.course-updates').present)
|
||||
|
||||
def is_course_update_list_empty(self):
|
||||
"""
|
||||
Checks whether or not the update contents list is empty
|
||||
"""
|
||||
return len(self.q(css='.update-contents')) == 0
|
||||
|
||||
def is_new_update_button_present(self):
|
||||
"""
|
||||
Checks for the presence of the new update post button.
|
||||
"""
|
||||
return self.q(css='.new-update-button').present
|
||||
|
||||
def click_new_update_button(self):
|
||||
"""
|
||||
Clicks the new-update button.
|
||||
"""
|
||||
def is_update_button_enabled():
|
||||
"""
|
||||
Checks if the New Update button is enabled
|
||||
"""
|
||||
return self.q(css='.new-update-button').attrs('disabled')[0] is None
|
||||
|
||||
self.wait_for(promise_check_func=is_update_button_enabled,
|
||||
description='Waiting for the New update button to be enabled.')
|
||||
click_css(self, '.new-update-button', require_notification=False)
|
||||
self.wait_for_element_visibility('.CodeMirror', 'Waiting for .CodeMirror')
|
||||
|
||||
def submit_update(self, message):
|
||||
"""
|
||||
Adds update text to the new update CodeMirror form and submits that text.
|
||||
|
||||
Arguments:
|
||||
message (str): The message to be added and saved.
|
||||
"""
|
||||
type_in_codemirror(self, 0, message)
|
||||
self.click_new_update_save_button()
|
||||
|
||||
def set_date(self, date):
|
||||
"""
|
||||
Sets the updates date input to the provided value.
|
||||
|
||||
Arguments:
|
||||
date (str): Date string in the format DD/MM/YYYY
|
||||
"""
|
||||
set_input_value(self, 'input.date', date)
|
||||
|
||||
def is_first_update_date(self, search_date):
|
||||
"""
|
||||
Checks to see if the search date is present
|
||||
|
||||
Arguments:
|
||||
search_date (str): e.g. 06/01/2013 would be found with June 1, 2013
|
||||
|
||||
Returns:
|
||||
bool: True if the date is in the first update and False otherwise.
|
||||
"""
|
||||
return search_date == self.q(css='.date-display').html[0]
|
||||
|
||||
def is_new_update_save_button_present(self):
|
||||
"""
|
||||
Checks to see if the CodeMirror Update save button is present.
|
||||
"""
|
||||
return self.q(css='.save-button').present
|
||||
|
||||
def click_new_update_save_button(self):
|
||||
"""
|
||||
Clicks the CodeMirror Update save button.
|
||||
"""
|
||||
click_css(self, '.save-button')
|
||||
|
||||
def is_edit_button_present(self):
|
||||
"""
|
||||
Checks to see if the edit update post buttons if present.
|
||||
"""
|
||||
return self.q(css='.post-preview .edit-button').present
|
||||
|
||||
def click_edit_update_button(self):
|
||||
"""
|
||||
Clicks the edit update post button.
|
||||
"""
|
||||
click_css(self, '.post-preview .edit-button', require_notification=False)
|
||||
self.wait_for_element_visibility('.CodeMirror', 'Waiting for .CodeMirror')
|
||||
|
||||
def is_delete_update_button_present(self):
|
||||
"""
|
||||
Checks to see if the delete update post button is present.
|
||||
"""
|
||||
return self.q(css='.post-preview .delete-button').present
|
||||
|
||||
def click_delete_update_button(self):
|
||||
"""
|
||||
Clicks the delete update post button and confirms the delete notification.
|
||||
"""
|
||||
click_css(self, '.post-preview .delete-button', require_notification=False)
|
||||
confirm_prompt(self)
|
||||
|
||||
def is_first_update_message(self, message):
|
||||
"""
|
||||
Looks for the message in the first course update posted.
|
||||
|
||||
Arguments:
|
||||
message (str): String containing the message that is to be searched for
|
||||
|
||||
Returns:
|
||||
bool: True if the first update is the message, false otherwise.
|
||||
"""
|
||||
return message == self.q(css='.update-contents').html[0]
|
||||
|
||||
def first_update_contains_html(self, value):
|
||||
"""
|
||||
Looks to see if the html provided is contained in the first update
|
||||
|
||||
Arguments:
|
||||
value (str): String value that will be looked for
|
||||
|
||||
Returns:
|
||||
bool: True if the value is contained in the first update
|
||||
"""
|
||||
update = self.q(css='.update-contents').html
|
||||
return value in update[0]
|
||||
@@ -1,35 +0,0 @@
|
||||
"""
|
||||
Discussion component editor in studio
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css
|
||||
from common.test.acceptance.pages.studio.xblock_editor import XBlockEditorView
|
||||
|
||||
|
||||
class DiscussionComponentEditor(XBlockEditorView):
|
||||
"""
|
||||
Discussion Editor view in studio
|
||||
"""
|
||||
@property
|
||||
def edit_discussion_field_values(self):
|
||||
"""
|
||||
Get field values of discussion edit dialogue.
|
||||
Returns:
|
||||
list: A list of string indicating field values
|
||||
"""
|
||||
return self.q(css='.field-data-control').attrs('value')
|
||||
|
||||
def set_field_val(self, field_display_name, field_value):
|
||||
"""
|
||||
If editing, set the value of a field.
|
||||
"""
|
||||
selector = u'.xblock-studio_view li.field label:contains("{}") + input'.format(field_display_name)
|
||||
script = "$(arguments[0]).val(arguments[1]).change();"
|
||||
self.browser.execute_script(script, selector, field_value)
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
click_css(self, '.save-button')
|
||||
@@ -1,162 +0,0 @@
|
||||
"""
|
||||
Pages page for a course.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from selenium.webdriver import ActionChains
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css, confirm_prompt
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
|
||||
|
||||
class PagesPage(CoursePage):
|
||||
"""
|
||||
Pages page for a course.
|
||||
"""
|
||||
|
||||
url_path = "tabs"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='body.view-static-pages').present
|
||||
|
||||
def is_static_page_present(self):
|
||||
"""
|
||||
Checks for static tab's presence
|
||||
|
||||
Returns:
|
||||
bool: True if present
|
||||
"""
|
||||
return self.q(css='.wrapper.wrapper-component-action-header').present
|
||||
|
||||
def add_static_page(self):
|
||||
"""
|
||||
Adds a static page
|
||||
"""
|
||||
total_tabs = len(self.q(css='.course-nav-list>li'))
|
||||
click_css(self, '.add-pages .new-tab', require_notification=False)
|
||||
self.wait_for(
|
||||
lambda: len(self.q(css='.course-nav-list>li')) == total_tabs + 1,
|
||||
description="Static tab is added"
|
||||
)
|
||||
self.wait_for_element_visibility(
|
||||
u'.tab-list :nth-child({}) .xblock-student_view'.format(total_tabs),
|
||||
'Static tab is visible'
|
||||
)
|
||||
# self.wait_for_ajax()
|
||||
|
||||
def delete_static_tab(self):
|
||||
"""
|
||||
Deletes a static page
|
||||
"""
|
||||
click_css(self, '.btn-default.delete-button.action-button', require_notification=False)
|
||||
confirm_prompt(self)
|
||||
|
||||
def click_edit_static_page(self):
|
||||
"""
|
||||
Clicks on edit button to open up the xblock modal
|
||||
"""
|
||||
self.q(css='.edit-button').first.click()
|
||||
EmptyPromise(
|
||||
lambda: self.q(css='.xblock-studio_view').present,
|
||||
'Wait for the Studio editor to be present'
|
||||
).fulfill()
|
||||
|
||||
def drag_and_drop_first_static_page_to_last(self):
|
||||
"""
|
||||
Drags and drops the first the static page to the last
|
||||
"""
|
||||
draggable_elements = self.q(css='.component .drag-handle').results
|
||||
source_element = draggable_elements[0]
|
||||
target_element = self.q(css='.new-component-item').results[0]
|
||||
action = ActionChains(self.browser)
|
||||
action.drag_and_drop(source_element, target_element).perform()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def drag_and_drop(self, default_tab=False):
|
||||
"""
|
||||
Drags and drops the first static page to the last
|
||||
"""
|
||||
css_selector = '.component .drag-handle'
|
||||
if default_tab:
|
||||
css_selector = '.drag-handle.action'
|
||||
source_element = self.q(css=css_selector).results[0]
|
||||
target_element = self.q(css='.new-component-item').results[0]
|
||||
action = ActionChains(self.browser)
|
||||
action.drag_and_drop(source_element, target_element).perform()
|
||||
self.wait_for_ajax()
|
||||
|
||||
@property
|
||||
def static_tab_titles(self):
|
||||
"""
|
||||
Return titles of all static tabs
|
||||
Returns:
|
||||
list: list of all the titles
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'.wrapper-component-action-header .component-actions',
|
||||
"Tab's edit button is visible"
|
||||
)
|
||||
return self.q(css='div.xmodule_StaticTabBlock').text
|
||||
|
||||
@property
|
||||
def built_in_page_titles(self):
|
||||
"""
|
||||
Gets the default tab title
|
||||
Returns:
|
||||
list: list of all the titles
|
||||
"""
|
||||
return self.q(css='.course-nav-list.ui-sortable h3').text
|
||||
|
||||
def open_settings_tab(self):
|
||||
"""
|
||||
Clicks settings tab
|
||||
"""
|
||||
self.q(css='.editor-modes .settings-button').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def is_tab_visible(self, tab_name):
|
||||
"""
|
||||
Checks for the tab's visibility
|
||||
Args:
|
||||
tab_name(string): Name of the tab for which visibility is to be checked
|
||||
Returns:
|
||||
true(bool): if tab is visible
|
||||
false(bool): if tab is not visible
|
||||
"""
|
||||
css_selector = u'[data-tab-id="{}"] .toggle-checkbox'.format(tab_name)
|
||||
return True if not self.q(css=css_selector).selected else False
|
||||
|
||||
def toggle_tab(self, tab_name):
|
||||
"""
|
||||
Toggles the visibility on tab
|
||||
Args:
|
||||
tab_name(string): Name of the tab to be toggled
|
||||
"""
|
||||
css_selector = u'[data-tab-id="{}"] .action-visible'.format(tab_name)
|
||||
return self.q(css=css_selector).first.click()
|
||||
|
||||
def set_field_val(self, field_display_name, field_value):
|
||||
"""
|
||||
Set the value of a field in editor
|
||||
|
||||
Arguments:
|
||||
field_display_name(str): Display name of the field for which the value is to be changed
|
||||
field_value(str): New value for the field
|
||||
"""
|
||||
selector = u'.xblock-studio_view li.field label:contains("{}") + input'.format(field_display_name)
|
||||
script = '$(arguments[0]).val(arguments[1]).change();'
|
||||
self.browser.execute_script(script, selector, field_value)
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
click_css(self, '.action-save')
|
||||
|
||||
def refresh_and_wait_for_load(self):
|
||||
"""
|
||||
Refresh the page and wait for all resources to load.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
self.wait_for_page()
|
||||
@@ -1,289 +0,0 @@
|
||||
"""
|
||||
HTML component editor in studio
|
||||
"""
|
||||
|
||||
|
||||
from six.moves import zip
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css
|
||||
from common.test.acceptance.pages.studio.utils import get_codemirror_value, type_in_codemirror
|
||||
from common.test.acceptance.pages.studio.xblock_editor import XBlockEditorView
|
||||
|
||||
|
||||
class HtmlXBlockEditorView(XBlockEditorView):
|
||||
"""
|
||||
Represents the rendered view of an HTML component editor.
|
||||
"""
|
||||
|
||||
editor_mode_css = '.edit-xblock-modal .editor-modes .editor-button'
|
||||
settings_tab = '.editor-modes .settings-button'
|
||||
save_settings_button = '.action-save'
|
||||
|
||||
@property
|
||||
def toolbar_dropdown_titles(self):
|
||||
"""
|
||||
Returns the titles of dropdowns present on the toolbar
|
||||
"""
|
||||
return self.q(css='.mce-listbox').text
|
||||
|
||||
@property
|
||||
def toolbar_button_titles(self):
|
||||
"""
|
||||
Returns the titles of the buttons present on the toolbar
|
||||
Returns:
|
||||
|
||||
"""
|
||||
return self.q(css='.mce-ico').attrs('class')
|
||||
|
||||
@property
|
||||
def fonts(self):
|
||||
"""
|
||||
Available fonts in the font dropdown
|
||||
Returns:
|
||||
(list): A list of font names
|
||||
"""
|
||||
return self.q(css='.mce-text').text
|
||||
|
||||
@property
|
||||
def font_families(self):
|
||||
"""
|
||||
Available font families against each font
|
||||
Returns:
|
||||
(list): A list of font families
|
||||
"""
|
||||
return self.q(css='.mce-text').attrs('style')
|
||||
|
||||
def open_font_dropdown(self):
|
||||
"""
|
||||
Clicks and waits for font dropdown to open
|
||||
"""
|
||||
self.q(css='#mce_2-open').first.click()
|
||||
self.wait_for_element_visibility('.mce-floatpanel', 'Dropdown is Visible')
|
||||
|
||||
def font_dict(self):
|
||||
"""
|
||||
Creates a dictionary with font labels and font families
|
||||
Returns:
|
||||
font_dict(dict): A dictionary of font labels as keys and font families as values
|
||||
"""
|
||||
font_labels = self.fonts
|
||||
font_families = self.font_families
|
||||
for index, font in enumerate(font_families):
|
||||
font = font.replace('font-family: ', '').replace(';', '')
|
||||
font_families[index] = font.split(',')
|
||||
font_families[index] = [x.lstrip() for x in font_families[index]]
|
||||
font_dict = dict(list(zip(font_labels, font_families)))
|
||||
return font_dict
|
||||
|
||||
def set_content_and_save(self, content, raw=False):
|
||||
"""Types content into the html component and presses Save.
|
||||
|
||||
Arguments:
|
||||
content (str): The content to be used.
|
||||
raw (bool): If true, edits in 'raw HTML' mode.
|
||||
"""
|
||||
if raw:
|
||||
self.set_raw_content(content)
|
||||
else:
|
||||
self.set_content(content)
|
||||
|
||||
self.save()
|
||||
|
||||
def set_content_and_cancel(self, content, raw=False):
|
||||
"""Types content into the html component and presses Cancel to abort.
|
||||
|
||||
Arguments:
|
||||
content (str): The content to be used.
|
||||
raw (bool): If true, edits in 'raw HTML' mode.
|
||||
"""
|
||||
if raw:
|
||||
self.set_raw_content(content)
|
||||
else:
|
||||
self.set_content(content)
|
||||
|
||||
self.cancel()
|
||||
|
||||
def set_content(self, content):
|
||||
"""Sets content in the html component, leaving the component open.
|
||||
|
||||
Arguments:
|
||||
content (str): The content to be used.
|
||||
"""
|
||||
self.q(css=self.editor_mode_css).click()
|
||||
self.browser.execute_script("tinyMCE.activeEditor.setContent('%s')" % content)
|
||||
|
||||
def set_raw_content(self, content):
|
||||
"""Types content in raw html mode, leaving the component open.
|
||||
Arguments:
|
||||
content (str): The content to be used.
|
||||
"""
|
||||
self.q(css=self.editor_mode_css).click()
|
||||
self.q(css='[aria-label="Edit HTML"]').click()
|
||||
self.wait_for_element_visibility('.mce-title', 'Wait for CodeMirror editor')
|
||||
# Set content in the CodeMirror editor.
|
||||
type_in_codemirror(self, 0, content)
|
||||
|
||||
self.q(css='.mce-foot .mce-primary').click()
|
||||
|
||||
def open_settings_tab(self):
|
||||
"""
|
||||
Clicks settings button on the modal
|
||||
"""
|
||||
click_css(self, self.settings_tab)
|
||||
|
||||
def save_settings(self):
|
||||
"""
|
||||
Click save button on the modal
|
||||
"""
|
||||
click_css(self, self.save_settings_button)
|
||||
|
||||
def open_raw_editor(self):
|
||||
"""
|
||||
Clicks and waits for raw editor to open
|
||||
"""
|
||||
self.q(css='[aria-label="Edit HTML"]').click()
|
||||
self.wait_for_element_visibility('.mce-title', 'Wait for CodeMirror editor')
|
||||
|
||||
def open_link_plugin(self):
|
||||
"""
|
||||
Opens up the link plugin on editor
|
||||
"""
|
||||
self.q(css='[aria-label="Insert/edit link"]').click()
|
||||
self.wait_for_element_visibility('.mce-window-head', 'Window header present')
|
||||
|
||||
def save_static_link(self, static_link):
|
||||
"""
|
||||
Adds static link inside the link plugin
|
||||
"""
|
||||
self.q(css='.mce-combobox .mce-textbox').fill(static_link)
|
||||
self.q(css='.mce-btn.mce-primary').click()
|
||||
|
||||
@property
|
||||
def href(self):
|
||||
"""
|
||||
Gets the href from the editor
|
||||
"""
|
||||
return self.q(css="#tinymce>p>a").attrs('href')[0]
|
||||
|
||||
@property
|
||||
def editor_value(self):
|
||||
"""
|
||||
Returns codemirror value from raw HTMl editor
|
||||
"""
|
||||
return get_codemirror_value(self, 0)
|
||||
|
||||
def switch_to_iframe(self):
|
||||
"""
|
||||
Switches to the editor iframe
|
||||
"""
|
||||
self.browser.switch_to_frame(self.browser.find_element_by_tag_name('iframe'))
|
||||
|
||||
@property
|
||||
def url_from_the_link_plugin(self):
|
||||
"""
|
||||
Clicks the already set link from the editor and then returns the URL from the link plugin
|
||||
"""
|
||||
self.open_link_plugin()
|
||||
return self.browser.execute_script('return $(".mce-textbox").val();')
|
||||
|
||||
def set_text_and_select(self, text):
|
||||
"""
|
||||
Sets and selects text from html editor
|
||||
"""
|
||||
script = """
|
||||
var editor = tinyMCE.activeEditor;
|
||||
editor.setContent(arguments[0]);
|
||||
editor.selection.select(editor.dom.select('p')[0]);"""
|
||||
self.browser.execute_script(script, str(text))
|
||||
self.wait_for_ajax()
|
||||
|
||||
def click_code_toolbar_button(self):
|
||||
"""
|
||||
Clicks on the code plugin on the toolbar
|
||||
"""
|
||||
self.q(css='.mce-i-none').first.click()
|
||||
|
||||
def get_default_settings(self):
|
||||
"""
|
||||
Returns default display name and editor
|
||||
"""
|
||||
display_name_setting = self.q(css='.wrapper-comp-setting input[type="text"]:nth-child(2)').attrs('value')[0]
|
||||
editor_setting = self.q(css='.wrapper-comp-setting .input.setting-input :nth-child(1)').text[0]
|
||||
return [display_name_setting, editor_setting]
|
||||
|
||||
@property
|
||||
def keys(self):
|
||||
"""
|
||||
Gets setting keys
|
||||
"""
|
||||
return self.q(css='.label.setting-label[for]').text
|
||||
|
||||
def set_field_val(self, field_display_name, field_value):
|
||||
"""
|
||||
If editing, set the value of a field.
|
||||
"""
|
||||
selector = u'.xblock-studio_view li.field label:contains("{}") + input'.format(field_display_name)
|
||||
script = "$(arguments[0]).val(arguments[1]).change();"
|
||||
self.browser.execute_script(script, selector, field_value)
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
click_css(self, '.save-button')
|
||||
|
||||
def save_content(self):
|
||||
"""
|
||||
Click save button
|
||||
"""
|
||||
click_css(self, '.action-save')
|
||||
|
||||
def open_image_modal(self):
|
||||
"""
|
||||
Clicks and in insert image button
|
||||
"""
|
||||
click_css(self, 'div i[class="mce-ico mce-i-image"]')
|
||||
|
||||
def upload_image(self, file_name):
|
||||
"""
|
||||
Upload image and add description and click save to upload image via TinyMCE editor.
|
||||
"""
|
||||
file_input_css = "[type='file']"
|
||||
|
||||
# select file input element and change visibility to add file.
|
||||
self.browser.execute_script('$("{}").css("display","block");'.format(file_input_css))
|
||||
self.wait_for_element_visibility(file_input_css, "Input is visible")
|
||||
self.q(css=file_input_css).results[0].send_keys(file_name)
|
||||
self.wait_for_element_visibility('#imageDescription', 'Upload form is visible.')
|
||||
|
||||
self.q(css='#imageDescription').results[0].send_keys('test image')
|
||||
click_css(self, '.modal-footer .btn-primary')
|
||||
|
||||
|
||||
class HTMLEditorIframe(XBlockEditorView):
|
||||
"""
|
||||
Represent the iframe on HTMl editor view
|
||||
"""
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='#tinymce').present
|
||||
|
||||
@property
|
||||
def href(self):
|
||||
"""
|
||||
Gets the href from the editor
|
||||
"""
|
||||
return self.q(css="#tinymce>p>a").attrs('href')[0]
|
||||
|
||||
def select_link(self):
|
||||
"""
|
||||
Clicks the already set link from the editor and then returns the URL from the link plugin
|
||||
"""
|
||||
self.q(css='#tinymce>p>a').first.click()
|
||||
|
||||
def switch_to_default(self):
|
||||
"""
|
||||
Switches to default page
|
||||
|
||||
"""
|
||||
self.browser.switch_to_default_content()
|
||||
@@ -1,341 +0,0 @@
|
||||
"""
|
||||
Import/Export pages.
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
import six
|
||||
from bok_choy.promise import EmptyPromise
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css
|
||||
from common.test.acceptance.pages.studio import BASE_URL
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
from common.test.acceptance.pages.studio.library import LibraryPage
|
||||
|
||||
|
||||
class TemplateCheckMixin(object):
|
||||
"""
|
||||
Mixin for verifying that a template is loading the correct text.
|
||||
"""
|
||||
@property
|
||||
def header_text(self):
|
||||
"""
|
||||
Get the header text of the page.
|
||||
"""
|
||||
# There are prefixes like 'Tools' and '>', but the text itself is not in a span.
|
||||
return self.q(css='h1.page-header')[0].text.split('\n')[-1]
|
||||
|
||||
|
||||
class ImportExportMixin(object):
|
||||
"""
|
||||
Mixin for functionality common to both the import and export pages
|
||||
"""
|
||||
|
||||
def is_task_list_showing(self):
|
||||
"""
|
||||
The task list shows a series of steps being performed during import or
|
||||
export. It is normally hidden until the process begins.
|
||||
|
||||
Tell us whether it's currently visible.
|
||||
"""
|
||||
return self.q(css='.wrapper-status').visible
|
||||
|
||||
def is_timestamp_visible(self):
|
||||
"""
|
||||
Checks if the UTC timestamp of the last successful import/export is visible
|
||||
"""
|
||||
return self.q(css='.item-progresspoint-success-date').visible
|
||||
|
||||
@property
|
||||
def parsed_timestamp(self):
|
||||
"""
|
||||
Return python datetime object from the parsed timestamp tuple (date, time)
|
||||
"""
|
||||
timestamp = u"{0} {1}".format(*self.timestamp)
|
||||
formatted_timestamp = time.strptime(timestamp, u"%m/%d/%Y %H:%M")
|
||||
return datetime.fromtimestamp(time.mktime(formatted_timestamp))
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
"""
|
||||
The timestamp is displayed on the page as "(MM/DD/YYYY at HH:mm)"
|
||||
It parses the timestamp and returns a (date, time) tuple
|
||||
"""
|
||||
string = self.q(css='.item-progresspoint-success-date').text[0]
|
||||
|
||||
return re.match(six.text_type(r'\(([^ ]+).+?(\d{2}:\d{2})'), string).groups()
|
||||
|
||||
def wait_for_tasks(self, completed=False, fail_on=None):
|
||||
"""
|
||||
Wait for all of the items in the task list to be set to the correct state.
|
||||
"""
|
||||
if fail_on:
|
||||
# Makes no sense to include this if the tasks haven't run.
|
||||
completed = True
|
||||
|
||||
state, desc_template = self._task_properties(completed)
|
||||
|
||||
for desc, css_class in self.task_classes.items():
|
||||
desc_text = desc_template.format(desc)
|
||||
# pylint: disable=cell-var-from-loop
|
||||
EmptyPromise(lambda: self.q(css=u'.{}.{}'.format(css_class, state)).present, desc_text, timeout=30)
|
||||
if fail_on == desc:
|
||||
EmptyPromise(
|
||||
lambda: self.q(css=u'.{}.is-complete.has-error'.format(css_class)).present,
|
||||
u"{} checkpoint marked as failed".format(desc),
|
||||
timeout=30
|
||||
)
|
||||
# The rest should never run.
|
||||
state, desc_template = self._task_properties(False)
|
||||
|
||||
def wait_for_timestamp_visible(self):
|
||||
"""
|
||||
Wait for the timestamp of the last successful import/export to be visible.
|
||||
"""
|
||||
EmptyPromise(self.is_timestamp_visible, 'Timestamp Visible', timeout=30).fulfill()
|
||||
|
||||
@staticmethod
|
||||
def _task_properties(completed):
|
||||
"""
|
||||
Outputs the CSS class and promise description for task states based on completion.
|
||||
"""
|
||||
if completed:
|
||||
return 'is-complete', u"'{}' is marked complete"
|
||||
else:
|
||||
return 'is-not-started', u"'{}' is in not-yet-started status"
|
||||
|
||||
|
||||
class ExportMixin(ImportExportMixin):
|
||||
"""
|
||||
Export page Mixin.
|
||||
"""
|
||||
|
||||
url_path = "export"
|
||||
|
||||
task_classes = {
|
||||
'Preparing': 'item-progresspoint-prepare',
|
||||
'Exporting': 'item-progresspoint-export',
|
||||
'Compressing': 'item-progresspoint-compress',
|
||||
'Success': 'item-progresspoint-success'
|
||||
}
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Verify this is the export page
|
||||
"""
|
||||
return self.q(css='body.view-export').present
|
||||
|
||||
def is_click_handler_registered(self):
|
||||
"""
|
||||
Check if the click handler for the export button has been registered yet
|
||||
"""
|
||||
script = """
|
||||
var $ = require('jquery'),
|
||||
buttonEvents = $._data($('a.action-primary')[0], 'events');
|
||||
return buttonEvents && buttonEvents.hasOwnProperty('click');"""
|
||||
stripped_script = ''.join([line.strip() for line in script.split('\n')])
|
||||
return self.browser.execute_script(stripped_script)
|
||||
|
||||
def _get_tarball(self, url):
|
||||
"""
|
||||
Download tarball at `url`
|
||||
"""
|
||||
kwargs = dict()
|
||||
session_id = [{i['name']: i['value']} for i in self.browser.get_cookies() if i['name'] == u'sessionid']
|
||||
if session_id:
|
||||
kwargs.update({
|
||||
'cookies': session_id[0]
|
||||
})
|
||||
|
||||
response = requests.get(url, **kwargs)
|
||||
|
||||
return response.status_code == 200, response.headers
|
||||
|
||||
def download_tarball(self):
|
||||
"""
|
||||
Downloads the course or library in tarball form.
|
||||
"""
|
||||
tarball_url = self.q(css='#download-exported-button')[0].get_attribute('href')
|
||||
good_status, headers = self._get_tarball(tarball_url)
|
||||
return good_status, headers['content-type'] == 'application/x-tgz'
|
||||
|
||||
def click_export(self):
|
||||
"""
|
||||
Click the export button.
|
||||
"""
|
||||
self.q(css='a.action-export').click()
|
||||
|
||||
def is_error_modal_showing(self):
|
||||
"""
|
||||
Indicates whether or not the error modal is showing.
|
||||
"""
|
||||
return self.q(css='.prompt.error').visible
|
||||
|
||||
def is_export_finished(self):
|
||||
"""
|
||||
Checks if the 'Download Exported Course/Library' button is showing.
|
||||
"""
|
||||
button = self.q(css='#download-exported-button')[0]
|
||||
return button.is_displayed() and button.get_attribute('href')
|
||||
|
||||
def click_modal_button(self):
|
||||
"""
|
||||
Click the button on the modal dialog that appears when there's a problem.
|
||||
"""
|
||||
self.q(css='.prompt.error .action-primary').click()
|
||||
|
||||
def wait_for_error_modal(self):
|
||||
"""
|
||||
If an import or export has an error, an error modal will be shown.
|
||||
"""
|
||||
EmptyPromise(self.is_error_modal_showing, 'Error Modal Displayed', timeout=30).fulfill()
|
||||
|
||||
def wait_for_export(self):
|
||||
"""
|
||||
Wait for the export process to finish.
|
||||
"""
|
||||
EmptyPromise(self.is_export_finished, 'Export Finished', timeout=30).fulfill()
|
||||
|
||||
def wait_for_export_click_handler(self):
|
||||
"""
|
||||
Wait for the export button click handler to be registered
|
||||
"""
|
||||
EmptyPromise(self.is_click_handler_registered, 'Export Button Click Handler Registered', timeout=30).fulfill()
|
||||
|
||||
|
||||
class LibraryLoader(object):
|
||||
"""
|
||||
URL loading mixing for Library import/export
|
||||
"""
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
This pattern isn't followed universally by library URLs,
|
||||
but is used for import/export.
|
||||
"""
|
||||
# pylint: disable=no-member
|
||||
return "/".join([BASE_URL, self.url_path, six.text_type(self.locator)])
|
||||
|
||||
|
||||
class ExportCoursePage(ExportMixin, TemplateCheckMixin, CoursePage):
|
||||
"""
|
||||
Export page for Courses
|
||||
"""
|
||||
|
||||
|
||||
class ExportLibraryPage(ExportMixin, TemplateCheckMixin, LibraryLoader, LibraryPage):
|
||||
"""
|
||||
Export page for Libraries
|
||||
"""
|
||||
|
||||
|
||||
class ImportMixin(ImportExportMixin):
|
||||
"""
|
||||
Import page mixin
|
||||
"""
|
||||
|
||||
url_path = "import"
|
||||
|
||||
task_classes = {
|
||||
'Uploading': 'item-progresspoint-upload',
|
||||
'Unpacking': 'item-progresspoint-unpack',
|
||||
'Verifying': 'item-progresspoint-verify',
|
||||
'Updating': 'item-progresspoint-import',
|
||||
'Success': 'item-progresspoint-success'
|
||||
}
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Verify this is the export page
|
||||
"""
|
||||
return self.q(css='.choose-file-button').present
|
||||
|
||||
@staticmethod
|
||||
def file_path(filename):
|
||||
"""
|
||||
Construct file path to be uploaded from the data upload folder.
|
||||
|
||||
Arguments:
|
||||
filename (str): asset filename
|
||||
|
||||
"""
|
||||
# Should grab common point between this page module and the data folder.
|
||||
return os.sep.join(__file__.split(os.sep)[:-4]) + '/data/imports/' + filename
|
||||
|
||||
def _wait_for_button(self):
|
||||
"""
|
||||
Wait for the upload button to appear.
|
||||
"""
|
||||
return EmptyPromise(
|
||||
lambda: self.q(css='#replace-courselike-button')[0],
|
||||
"Upload button appears",
|
||||
timeout=30
|
||||
).fulfill()
|
||||
|
||||
def upload_tarball(self, tarball_filename):
|
||||
"""
|
||||
Upload a tarball to be imported.
|
||||
"""
|
||||
asset_file_path = self.file_path(tarball_filename)
|
||||
# Make the upload elements visible to the WebDriver.
|
||||
self.browser.execute_script('$(".file-name-block").show();$(".file-input").show()')
|
||||
# Upload the file.
|
||||
self.q(css='input[type="file"]')[0].send_keys(asset_file_path)
|
||||
# Upload the same file again. Reason behind this is to decrease the
|
||||
# probability or fraction of times the failure occur. Please be
|
||||
# noted this doesn't eradicate the root cause of the error, it
|
||||
# just decreases to failure rate to minimal.
|
||||
# Jira ticket reference: TNL-4191.
|
||||
self.q(css='input[type="file"]')[0].send_keys(asset_file_path)
|
||||
# Some of the tests need these lines to pass so don't remove them.
|
||||
self._wait_for_button()
|
||||
click_css(self, '.submit-button', require_notification=True)
|
||||
|
||||
def is_upload_finished(self):
|
||||
"""
|
||||
Checks if the 'view updated' button is showing.
|
||||
"""
|
||||
return self.q(css='#view-updated-button').visible
|
||||
|
||||
def wait_for_upload(self):
|
||||
"""
|
||||
Wait for the upload to be confirmed.
|
||||
"""
|
||||
EmptyPromise(self.is_upload_finished, 'Upload Finished', timeout=30).fulfill()
|
||||
|
||||
def is_filename_error_showing(self):
|
||||
"""
|
||||
An should be shown if the user tries to upload the wrong kind of file.
|
||||
|
||||
Tell us whether it's currently being shown.
|
||||
"""
|
||||
return self.q(css='#fileupload .error-block').visible
|
||||
|
||||
def wait_for_filename_error(self):
|
||||
"""
|
||||
Wait for the upload field to display an error.
|
||||
"""
|
||||
EmptyPromise(self.is_filename_error_showing, 'Upload Error Displayed', timeout=30).fulfill()
|
||||
|
||||
def finished_target_url(self):
|
||||
"""
|
||||
Grab the URL of the 'view updated library/course outline' button.
|
||||
"""
|
||||
return self.q(css='.action.action-primary')[0].get_attribute('href')
|
||||
|
||||
|
||||
class ImportCoursePage(ImportMixin, TemplateCheckMixin, CoursePage):
|
||||
"""
|
||||
Import page for Courses
|
||||
"""
|
||||
|
||||
|
||||
class ImportLibraryPage(ImportMixin, TemplateCheckMixin, LibraryLoader, LibraryPage):
|
||||
"""
|
||||
Import page for Libraries
|
||||
"""
|
||||
@@ -1,389 +0,0 @@
|
||||
"""
|
||||
Studio Index, home and dashboard pages. These are the starting pages for users.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from selenium.webdriver import ActionChains
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from common.test.acceptance.pages.studio import BASE_URL
|
||||
from common.test.acceptance.pages.studio.login import LoginPage
|
||||
from common.test.acceptance.pages.studio.signup import SignupPage
|
||||
from common.test.acceptance.pages.studio.utils import HelpMixin
|
||||
|
||||
|
||||
class HeaderMixin(object):
|
||||
"""
|
||||
Mixin class used for the pressing buttons in the header.
|
||||
"""
|
||||
def click_sign_up(self):
|
||||
"""
|
||||
Press the Sign Up button in the header.
|
||||
"""
|
||||
next_page = SignupPage(self.browser)
|
||||
self.q(css='.action-signup')[0].click()
|
||||
return next_page.wait_for_page()
|
||||
|
||||
def click_sign_in(self):
|
||||
"""
|
||||
Press the Sign In button in the header.
|
||||
"""
|
||||
next_page = LoginPage(self.browser)
|
||||
self.q(css='.action-signin')[0].click()
|
||||
return next_page.wait_for_page()
|
||||
|
||||
|
||||
class IndexPage(PageObject, HeaderMixin, HelpMixin):
|
||||
"""
|
||||
Home page for Studio when not logged in.
|
||||
"""
|
||||
url = BASE_URL + "/"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='.wrapper-text-welcome').visible
|
||||
|
||||
|
||||
class DashboardPage(PageObject, HelpMixin):
|
||||
"""
|
||||
Studio Dashboard page with courses.
|
||||
The user must be logged in to access this page.
|
||||
"""
|
||||
url = BASE_URL + "/course/"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='.content-primary').visible
|
||||
|
||||
@property
|
||||
def course_runs(self):
|
||||
"""
|
||||
The list of course run metadata for all displayed courses
|
||||
Returns an empty string if there are none
|
||||
"""
|
||||
return self.q(css='.course-run>.value').text
|
||||
|
||||
@property
|
||||
def has_processing_courses(self):
|
||||
return self.q(css='.courses-processing').present
|
||||
|
||||
def create_rerun(self, course_key):
|
||||
"""
|
||||
Clicks the create rerun link of the course specified by course_key
|
||||
'Re-run course' link doesn't show up until you mouse over that course in the course listing
|
||||
"""
|
||||
actions = ActionChains(self.browser)
|
||||
button_name = self.browser.find_element_by_css_selector('.rerun-button[href$="' + course_key + '"]')
|
||||
actions.move_to_element(button_name)
|
||||
actions.click(button_name)
|
||||
actions.perform()
|
||||
|
||||
def click_course_run(self, run):
|
||||
"""
|
||||
Clicks on the course with run given by run.
|
||||
"""
|
||||
self.q(css='.course-run .value').filter(lambda el: el.text == run)[0].click()
|
||||
# Clicking on course with run will trigger an ajax event
|
||||
self.wait_for_ajax()
|
||||
|
||||
def scroll_to_course(self, course_key):
|
||||
"""
|
||||
Scroll down to the course element
|
||||
"""
|
||||
element = '[data-course-key*="{}"]'.format(course_key)
|
||||
self.scroll_to_element(element)
|
||||
|
||||
def has_new_library_button(self):
|
||||
"""
|
||||
(bool) is the "New Library" button present?
|
||||
"""
|
||||
return self.q(css='.new-library-button').present
|
||||
|
||||
def click_new_library(self):
|
||||
"""
|
||||
Click on the "New Library" button
|
||||
"""
|
||||
self.q(css='.new-library-button').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def is_new_library_form_visible(self):
|
||||
"""
|
||||
Is the new library form visisble?
|
||||
"""
|
||||
return self.q(css='.wrapper-create-library').visible
|
||||
|
||||
def fill_new_library_form(self, display_name, org, number):
|
||||
"""
|
||||
Fill out the form to create a new library.
|
||||
Must have called click_new_library() first.
|
||||
"""
|
||||
field = lambda fn: self.q(css=u'.wrapper-create-library #new-library-{}'.format(fn))
|
||||
field('name').fill(display_name)
|
||||
field('org').fill(org)
|
||||
field('number').fill(number)
|
||||
|
||||
def is_new_library_form_valid(self):
|
||||
"""
|
||||
Is the new library form ready to submit?
|
||||
"""
|
||||
return (
|
||||
self.q(css='.wrapper-create-library .new-library-save:not(.is-disabled)').present and
|
||||
not self.q(css='.wrapper-create-library .wrap-error.is-shown').present
|
||||
)
|
||||
|
||||
def submit_new_library_form(self):
|
||||
"""
|
||||
Submit the new library form.
|
||||
"""
|
||||
self.q(css='.wrapper-create-library .new-library-save').click()
|
||||
|
||||
@property
|
||||
def new_course_button(self):
|
||||
"""
|
||||
Returns "New Course" button.
|
||||
"""
|
||||
return self.q(css='.new-course-button')
|
||||
|
||||
def is_new_course_form_visible(self):
|
||||
"""
|
||||
Is the new course form visible?
|
||||
"""
|
||||
return self.q(css='.wrapper-create-course').visible
|
||||
|
||||
def click_new_course_button(self):
|
||||
"""
|
||||
Click "New Course" button
|
||||
"""
|
||||
self.q(css='.new-course-button').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def fill_new_course_form(self, display_name, org, number, run):
|
||||
"""
|
||||
Fill out the form to create a new course.
|
||||
"""
|
||||
field = lambda fn: self.q(css=u'.wrapper-create-course #new-course-{}'.format(fn))
|
||||
field('name').fill(display_name)
|
||||
field('org').fill(org)
|
||||
field('number').fill(number)
|
||||
field('run').fill(run)
|
||||
|
||||
def is_new_course_form_valid(self):
|
||||
"""
|
||||
Returns `True` if new course form is valid otherwise `False`.
|
||||
"""
|
||||
return (
|
||||
self.q(css='.wrapper-create-course .new-course-save:not(.is-disabled)').present and
|
||||
not self.q(css='.wrapper-create-course .wrap-error.is-shown').present
|
||||
)
|
||||
|
||||
def submit_new_course_form(self):
|
||||
"""
|
||||
Submit the new course form.
|
||||
"""
|
||||
self.q(css='.wrapper-create-course .new-course-save').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
@property
|
||||
def error_notification(self):
|
||||
"""
|
||||
Returns error notification element.
|
||||
"""
|
||||
return self.q(css='.wrapper-notification-error.is-shown')
|
||||
|
||||
@property
|
||||
def error_notification_message(self):
|
||||
"""
|
||||
Returns text of error message.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
".wrapper-notification-error.is-shown .message", "Error message is visible"
|
||||
)
|
||||
return self.error_notification.results[0].find_element_by_css_selector('.message').text
|
||||
|
||||
@property
|
||||
def course_org_field(self):
|
||||
"""
|
||||
Returns course organization input.
|
||||
"""
|
||||
return self.q(css='.wrapper-create-course #new-course-org')
|
||||
|
||||
def select_item_in_autocomplete_widget(self, item_text):
|
||||
"""
|
||||
Selects item in autocomplete where text of item matches item_text.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
".ui-autocomplete .ui-menu-item", "Autocomplete widget is visible"
|
||||
)
|
||||
self.q(css='.ui-autocomplete .ui-menu-item a').filter(lambda el: el.text == item_text)[0].click()
|
||||
|
||||
def list_courses(self, archived=False):
|
||||
"""
|
||||
List all the courses found on the page's list of courses.
|
||||
"""
|
||||
# Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements
|
||||
tab_selector = u'#course-index-tabs .{} a'.format('archived-courses-tab' if archived else 'courses-tab')
|
||||
self.wait_for_element_presence(tab_selector, "Courses Tab")
|
||||
self.q(css=tab_selector).click()
|
||||
div2info = lambda element: {
|
||||
'name': element.find_element_by_css_selector('.course-title').text,
|
||||
'org': element.find_element_by_css_selector('.course-org .value').text,
|
||||
'number': element.find_element_by_css_selector('.course-num .value').text,
|
||||
'run': element.find_element_by_css_selector('.course-run .value').text,
|
||||
'url': element.find_element_by_css_selector('a.course-link').get_attribute('href'),
|
||||
}
|
||||
course_list_selector = u'.{} li.course-item'.format('archived-courses' if archived else 'courses')
|
||||
return self.q(css=course_list_selector).map(div2info).results
|
||||
|
||||
def has_course(self, org, number, run, archived=False):
|
||||
"""
|
||||
Returns `True` if course for given org, number and run exists on the page otherwise `False`
|
||||
"""
|
||||
for course in self.list_courses(archived):
|
||||
if course['org'] == org and course['number'] == number and course['run'] == run:
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_libraries(self):
|
||||
"""
|
||||
Click the tab to display the available libraries, and return detail of them.
|
||||
"""
|
||||
# Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements
|
||||
library_tab_css = '#course-index-tabs .libraries-tab'
|
||||
self.wait_for_element_presence(library_tab_css, "Libraries tab")
|
||||
self.q(css=library_tab_css).click()
|
||||
if self.q(css='.list-notices.libraries-tab').present:
|
||||
# No libraries are available.
|
||||
self.wait_for_element_presence('.libraries-tab .new-library-button', "new library tab")
|
||||
return []
|
||||
div2info = lambda element: {
|
||||
'name': element.find_element_by_css_selector('.course-title').text,
|
||||
'link_element': element.find_element_by_css_selector('.course-title'),
|
||||
'org': element.find_element_by_css_selector('.course-org .value').text,
|
||||
'number': element.find_element_by_css_selector('.course-num .value').text,
|
||||
'url': element.find_element_by_css_selector('a.library-link').get_attribute('href'),
|
||||
}
|
||||
self.wait_for_element_visibility('.libraries li.course-item', "Switch to library tab")
|
||||
return self.q(css='.libraries li.course-item').map(div2info).results
|
||||
|
||||
def has_library(self, **kwargs):
|
||||
"""
|
||||
Does the page's list of libraries include a library matching kwargs?
|
||||
"""
|
||||
for lib in self.list_libraries():
|
||||
if all([lib[key] == kwargs[key] for key in kwargs]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def click_library(self, name):
|
||||
"""
|
||||
Click on the library with the given name.
|
||||
"""
|
||||
for lib in self.list_libraries():
|
||||
if lib['name'] == name:
|
||||
lib['link_element'].click()
|
||||
|
||||
@property
|
||||
def language_selector(self):
|
||||
"""
|
||||
return language selector
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'#settings-language-value',
|
||||
'Language selector element is available'
|
||||
)
|
||||
return self.q(css='#settings-language-value')
|
||||
|
||||
@property
|
||||
def course_creation_error_message(self):
|
||||
"""
|
||||
Returns the course creation error
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'#course_creation_error>p',
|
||||
'Length error is present'
|
||||
)
|
||||
return self.q(css='#course_creation_error>p').text[0]
|
||||
|
||||
def is_create_button_disabled(self):
|
||||
"""
|
||||
Returns: True if Create button is disbaled
|
||||
"""
|
||||
self.wait_for_element_presence(
|
||||
'.action.action-primary.new-course-save.is-disabled',
|
||||
"Create button is disabled"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
class HomePage(DashboardPage):
|
||||
"""
|
||||
Home page for Studio when logged in.
|
||||
"""
|
||||
url = BASE_URL + "/home/"
|
||||
|
||||
|
||||
class AccessibilityPage(IndexPage):
|
||||
"""
|
||||
Home page for Studio when logged in.
|
||||
"""
|
||||
url = BASE_URL + "/accessibility"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Is the page header visible?
|
||||
"""
|
||||
return self.q(css='#root h2').visible
|
||||
|
||||
def header_text_on_page(self):
|
||||
"""
|
||||
Check that the page header has the right text.
|
||||
"""
|
||||
return 'Individualized Accessibility Process for Course Creators' in self.q(css='#root h2').text
|
||||
|
||||
def fill_form(self, email, name, message):
|
||||
"""
|
||||
Fill the accessibility feedback form out.
|
||||
"""
|
||||
email_input = self.q(css='#root input#email')
|
||||
name_input = self.q(css='#root input#fullName')
|
||||
message_input = self.q(css='#root textarea#message')
|
||||
|
||||
email_input.fill(email)
|
||||
name_input.fill(name)
|
||||
message_input.fill(message)
|
||||
|
||||
# Tab off the message textarea to trigger any error messages
|
||||
message_input[0].send_keys(Keys.TAB)
|
||||
|
||||
def submit_form(self):
|
||||
"""
|
||||
Click the submit button on the accessibiltiy feedback form.
|
||||
"""
|
||||
button = self.q(css='#root section button')[0]
|
||||
button.click()
|
||||
self.wait_for_element_visibility('#root div.alert-dialog', 'Form submission alert is visible')
|
||||
|
||||
def leave_field_blank(self, field_id, field_type='input'):
|
||||
"""
|
||||
To simulate leaving a field blank, click on the field, then press TAB to move off focus off the field.
|
||||
"""
|
||||
field = self.q(css=u'#root {}#{}'.format(field_type, field_id))[0]
|
||||
field.click()
|
||||
field.send_keys(Keys.TAB)
|
||||
|
||||
def alert_has_text(self, text=''):
|
||||
"""
|
||||
Check that the alert dialog contains the specified text.
|
||||
"""
|
||||
return text in self.q(css='#root div.alert-dialog').text
|
||||
|
||||
def error_message_is_shown_with_text(self, field_id, text=''):
|
||||
"""
|
||||
Check that at least one error message is shown and at least one contains the specified text.
|
||||
"""
|
||||
selector = u'#root div#error-{}'.format(field_id)
|
||||
self.wait_for_element_visibility(selector, 'An error message is visible')
|
||||
error_messages = self.q(css=selector)
|
||||
for message in error_messages:
|
||||
if text in message.text:
|
||||
return True
|
||||
return False
|
||||
@@ -4,19 +4,13 @@ Library edit page in Studio
|
||||
|
||||
|
||||
import six
|
||||
from bok_choy.javascript import js_defined, wait_for_js
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support.select import Select
|
||||
|
||||
from common.test.acceptance.pages.common.utils import confirm_prompt, sync_on_notification
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
from common.test.acceptance.pages.studio import BASE_URL
|
||||
from common.test.acceptance.pages.studio.container import XBlockWrapper
|
||||
from common.test.acceptance.pages.studio.pagination import PaginatedMixin
|
||||
from common.test.acceptance.pages.studio.users import UsersPageMixin
|
||||
from common.test.acceptance.pages.studio.utils import HelpMixin
|
||||
from common.test.acceptance.pages.studio.xblock_editor import XBlockEditorView
|
||||
|
||||
|
||||
class LibraryPage(PageObject, HelpMixin):
|
||||
@@ -46,12 +40,6 @@ class LibraryEditPage(LibraryPage, PaginatedMixin, UsersPageMixin):
|
||||
Library edit page in Studio
|
||||
"""
|
||||
|
||||
def get_header_title(self):
|
||||
"""
|
||||
The text of the main heading (H1) visible on the page.
|
||||
"""
|
||||
return self.q(css='h1.page-header-title').text
|
||||
|
||||
def wait_until_ready(self):
|
||||
"""
|
||||
When the page first loads, there is a loading indicator and most
|
||||
@@ -63,193 +51,3 @@ class LibraryEditPage(LibraryPage, PaginatedMixin, UsersPageMixin):
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
super(LibraryEditPage, self).wait_until_ready()
|
||||
|
||||
@property
|
||||
def xblocks(self):
|
||||
"""
|
||||
Return a list of xblocks loaded on the container page.
|
||||
"""
|
||||
return self._get_xblocks()
|
||||
|
||||
def are_previews_showing(self):
|
||||
"""
|
||||
Determines whether or not previews are showing for XBlocks
|
||||
"""
|
||||
return all([not xblock.is_placeholder() for xblock in self.xblocks])
|
||||
|
||||
def toggle_previews(self):
|
||||
"""
|
||||
Clicks the preview toggling button and waits for the previews to appear or disappear.
|
||||
"""
|
||||
toggle = not self.are_previews_showing()
|
||||
self.q(css='.toggle-preview-button').click()
|
||||
EmptyPromise(
|
||||
lambda: self.are_previews_showing() == toggle,
|
||||
u'Preview is visible: %s' % toggle,
|
||||
timeout=30
|
||||
).fulfill()
|
||||
self.wait_until_ready()
|
||||
|
||||
def click_duplicate_button(self, xblock_id):
|
||||
"""
|
||||
Click on the duplicate button for the given XBlock
|
||||
"""
|
||||
self._action_btn_for_xblock_id(xblock_id, "duplicate").click()
|
||||
sync_on_notification(self)
|
||||
self.wait_for_ajax()
|
||||
|
||||
def click_delete_button(self, xblock_id, confirm=True):
|
||||
"""
|
||||
Click on the delete button for the given XBlock
|
||||
"""
|
||||
self._action_btn_for_xblock_id(xblock_id, "delete").click()
|
||||
if confirm:
|
||||
confirm_prompt(self) # this will also sync_on_notification()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def _get_xblocks(self):
|
||||
"""
|
||||
Create an XBlockWrapper for each XBlock div found on the page.
|
||||
"""
|
||||
prefix = '.wrapper-xblock.level-page '
|
||||
return self.q(css=prefix + XBlockWrapper.BODY_SELECTOR).map(
|
||||
lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))
|
||||
).results
|
||||
|
||||
def _div_for_xblock_id(self, xblock_id):
|
||||
"""
|
||||
Given an XBlock's usage locator as a string, return the WebElement for
|
||||
that block's wrapper div.
|
||||
"""
|
||||
return self.q(css='.wrapper-xblock.level-page .studio-xblock-wrapper').filter(
|
||||
lambda el: el.get_attribute('data-locator') == xblock_id
|
||||
)
|
||||
|
||||
def _action_btn_for_xblock_id(self, xblock_id, action):
|
||||
"""
|
||||
Given an XBlock's usage locator as a string, return one of its action
|
||||
buttons.
|
||||
action is 'edit', 'duplicate', or 'delete'
|
||||
"""
|
||||
return self._div_for_xblock_id(xblock_id)[0].find_element_by_css_selector(
|
||||
u'.header-actions .{action}-button.action-button'.format(action=action)
|
||||
)
|
||||
|
||||
|
||||
class StudioLibraryContentEditor(XBlockEditorView):
|
||||
"""
|
||||
Library Content XBlock Modal edit window
|
||||
"""
|
||||
# Labels used to identify the fields on the edit modal:
|
||||
LIBRARY_LABEL = "Library"
|
||||
COUNT_LABEL = "Count"
|
||||
PROBLEM_TYPE_LABEL = "Problem Type"
|
||||
|
||||
@property
|
||||
def library_name(self):
|
||||
""" Gets name of library """
|
||||
return self.get_selected_option_text(self.LIBRARY_LABEL)
|
||||
|
||||
@library_name.setter
|
||||
def library_name(self, library_name):
|
||||
"""
|
||||
Select a library from the library select box
|
||||
"""
|
||||
self.set_select_value(self.LIBRARY_LABEL, library_name)
|
||||
EmptyPromise(lambda: self.library_name == library_name, "library_name is updated in modal.").fulfill()
|
||||
|
||||
@property
|
||||
def count(self):
|
||||
"""
|
||||
Gets value of children count input
|
||||
"""
|
||||
return int(self.get_setting_element(self.COUNT_LABEL).get_attribute('value'))
|
||||
|
||||
@count.setter
|
||||
def count(self, count):
|
||||
"""
|
||||
Sets value of children count input
|
||||
"""
|
||||
count_text = self.get_setting_element(self.COUNT_LABEL)
|
||||
count_text.send_keys(Keys.CONTROL, "a")
|
||||
count_text.send_keys(Keys.BACK_SPACE)
|
||||
count_text.send_keys(count)
|
||||
EmptyPromise(lambda: self.count == count, "count is updated in modal.").fulfill()
|
||||
|
||||
@property
|
||||
def capa_type(self):
|
||||
"""
|
||||
Gets value of CAPA type select
|
||||
"""
|
||||
return self.get_setting_element(self.PROBLEM_TYPE_LABEL).get_attribute('value')
|
||||
|
||||
@capa_type.setter
|
||||
def capa_type(self, value):
|
||||
"""
|
||||
Sets value of CAPA type select
|
||||
"""
|
||||
self.set_select_value(self.PROBLEM_TYPE_LABEL, value)
|
||||
EmptyPromise(lambda: self.capa_type == value, "problem type is updated in modal.").fulfill()
|
||||
|
||||
def set_select_value(self, label, value):
|
||||
"""
|
||||
Sets the select with given label (display name) to the specified value
|
||||
"""
|
||||
elem = self.get_setting_element(label)
|
||||
select = Select(elem)
|
||||
select.select_by_value(value)
|
||||
|
||||
|
||||
@js_defined('window.LibraryContentAuthorView')
|
||||
class StudioLibraryContainerXBlockWrapper(XBlockWrapper):
|
||||
"""
|
||||
Wraps :class:`.container.XBlockWrapper` for use with LibraryContent blocks
|
||||
"""
|
||||
url = None
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Returns true iff the library content area has been loaded
|
||||
"""
|
||||
return self.q(css='article.content-primary').visible
|
||||
|
||||
def is_finished_loading(self):
|
||||
"""
|
||||
Returns true iff the Loading indicator is not visible
|
||||
"""
|
||||
return not self.q(css='div.ui-loading').visible
|
||||
|
||||
@classmethod
|
||||
def from_xblock_wrapper(cls, xblock_wrapper):
|
||||
"""
|
||||
Factory method: creates :class:`.StudioLibraryContainerXBlockWrapper` from :class:`.container.XBlockWrapper`
|
||||
"""
|
||||
return cls(xblock_wrapper.browser, xblock_wrapper.locator)
|
||||
|
||||
def get_body_paragraphs(self):
|
||||
"""
|
||||
Gets library content body paragraphs
|
||||
"""
|
||||
return self.q(css=self._bounded_selector(".xblock-message-area p"))
|
||||
|
||||
@wait_for_js # Wait for the fragment.initialize_js('LibraryContentAuthorView') call to finish
|
||||
def refresh_children(self):
|
||||
"""
|
||||
Click "Update now..." button
|
||||
"""
|
||||
btn_selector = self._bounded_selector(".library-update-btn")
|
||||
self.wait_for_element_presence(btn_selector, 'Update now button is present.')
|
||||
self.q(css=btn_selector).first.click()
|
||||
|
||||
# This causes a reload (see cms/static/xmodule_js/public/js/library_content_edit.js)
|
||||
# Check that the ajax request that caused the reload is done.
|
||||
self.wait_for_ajax()
|
||||
# Then check that we are still on the right page.
|
||||
self.wait_for(lambda: self.is_browser_on_page(), 'StudioLibraryContainerXBlockWrapper has reloaded.')
|
||||
# Wait longer than the default 60 seconds, because this was intermittently failing on jenkins
|
||||
# with the screenshot showing that the Loading indicator was still visible. See TE-745.
|
||||
self.wait_for(lambda: self.is_finished_loading(), 'Loading indicator is not visible.', timeout=120)
|
||||
|
||||
# And wait to make sure the ajax post has finished.
|
||||
self.wait_for_ajax()
|
||||
self.wait_for_element_absence(btn_selector, 'Wait for the XBlock to finish reloading')
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"""
|
||||
Login page for Studio.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise
|
||||
|
||||
from common.test.acceptance.pages.studio import LMS_URL
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
from common.test.acceptance.pages.studio.utils import HelpMixin
|
||||
|
||||
|
||||
class LoginMixin(object):
|
||||
"""
|
||||
Mixin class used for logging into the system.
|
||||
"""
|
||||
def fill_password(self, password):
|
||||
"""
|
||||
Fill the password field with the value.
|
||||
"""
|
||||
self.q(css="#login-password").fill(password)
|
||||
|
||||
def login(self, email, password, expect_success=True):
|
||||
"""
|
||||
Attempt to log in using 'email' and 'password'.
|
||||
"""
|
||||
self.wait_for_element_visibility('#login-email', 'Email field is shown')
|
||||
self.q(css="#login-email").fill(email)
|
||||
self.fill_password(password)
|
||||
self.q(css=".login-button").click()
|
||||
|
||||
# Ensure that we make it to another page
|
||||
if expect_success:
|
||||
EmptyPromise(
|
||||
lambda: "login" not in self.browser.current_url,
|
||||
"redirected from the login page"
|
||||
).fulfill()
|
||||
|
||||
|
||||
class LoginPage(PageObject, LoginMixin, HelpMixin):
|
||||
"""
|
||||
Login page for Studio.
|
||||
"""
|
||||
url = LMS_URL + "/login"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return (
|
||||
self.q(css="#login-anchor").is_present() and
|
||||
self.q(css=".login-button").visible
|
||||
)
|
||||
|
||||
|
||||
class CourseOutlineSignInRedirectPage(CoursePage, LoginMixin):
|
||||
"""
|
||||
Page shown when the user tries to access the course while not signed in.
|
||||
"""
|
||||
url_path = "course"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css=".login-button").visible
|
||||
@@ -1,74 +0,0 @@
|
||||
"""
|
||||
Move XBlock Modal Page Object
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css
|
||||
|
||||
|
||||
class MoveModalView(PageObject):
|
||||
"""
|
||||
A base class for move xblock
|
||||
"""
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='.modal-window.move-modal').present
|
||||
|
||||
def url(self):
|
||||
"""
|
||||
Returns None because this is not directly accessible via URL.
|
||||
"""
|
||||
return None
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
click_css(self, 'a.action-save')
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Clicks cancel button.
|
||||
"""
|
||||
click_css(self, 'a.action-cancel', require_notification=False)
|
||||
|
||||
def click_forward_button(self, source_index):
|
||||
"""
|
||||
Click forward button at specified `source_index`.
|
||||
"""
|
||||
css = '.move-modal .xblock-items-container .xblock-item'
|
||||
self.q(css='.button-forward').nth(source_index).click()
|
||||
self.wait_for(
|
||||
lambda: len(self.q(css=css).results) > 0, description='children are visible'
|
||||
)
|
||||
|
||||
def click_move_button(self):
|
||||
"""
|
||||
Click move button.
|
||||
"""
|
||||
self.q(css='.modal-actions .action-move').first.click()
|
||||
|
||||
@property
|
||||
def is_move_button_enabled(self):
|
||||
"""
|
||||
Returns True if move button on modal is enabled else False.
|
||||
"""
|
||||
return not self.q(css='.modal-actions .action-move.is-disabled').present
|
||||
|
||||
@property
|
||||
def children_category(self):
|
||||
"""
|
||||
Get displayed children category.
|
||||
"""
|
||||
return self.q(css='.xblock-items-container').attrs('data-items-category')[0]
|
||||
|
||||
def navigate_to_category(self, category, navigation_options):
|
||||
"""
|
||||
Navigates to specifec `category` for a specified `source_index`.
|
||||
"""
|
||||
child_category = self.children_category
|
||||
while child_category != category:
|
||||
self.click_forward_button(navigation_options[child_category])
|
||||
child_category = self.children_category
|
||||
@@ -3,21 +3,13 @@ Course Outline page in Studio.
|
||||
"""
|
||||
|
||||
|
||||
import datetime
|
||||
|
||||
from bok_choy.javascript import js_defined, wait_for_js
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from selenium.webdriver import ActionChains
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support.ui import Select
|
||||
from six.moves import map, range
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css, confirm_prompt
|
||||
from common.test.acceptance.pages.studio.container import ContainerPage
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
from common.test.acceptance.pages.studio.utils import set_input_value, set_input_value_and_save
|
||||
from common.test.acceptance.tests.helpers import disable_animations, enable_animations, select_option_by_text
|
||||
|
||||
|
||||
@js_defined('jQuery')
|
||||
@@ -61,115 +53,6 @@ class CourseOutlineItem(object):
|
||||
else:
|
||||
return selector
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
Returns the display name of this object.
|
||||
"""
|
||||
name_element = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).first # pylint: disable=no-member
|
||||
if name_element:
|
||||
return name_element.text[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def has_status_message(self):
|
||||
"""
|
||||
Returns True if the item has a status message, False otherwise.
|
||||
"""
|
||||
return self.q(css=self._bounded_selector(self.STATUS_MESSAGE_SELECTOR)).first.visible # pylint: disable=no-member
|
||||
|
||||
@property
|
||||
def status_message(self):
|
||||
"""
|
||||
Returns the status message of this item.
|
||||
"""
|
||||
selector = self._bounded_selector(self.STATUS_MESSAGE_SELECTOR)
|
||||
return self.q(css=selector).text[0] # pylint: disable=no-member
|
||||
|
||||
@property
|
||||
def has_staff_lock_warning(self):
|
||||
""" Returns True if the 'Contains staff only content' message is visible """
|
||||
return self.status_message == 'Contains staff only content' if self.has_status_message else False
|
||||
|
||||
@property
|
||||
def has_restricted_warning(self):
|
||||
""" Returns True if the 'Access to this unit is restricted to' message is visible """
|
||||
return 'Access to this unit is restricted to' in self.status_message if self.has_status_message else False
|
||||
|
||||
@property
|
||||
def is_staff_only(self):
|
||||
""" Returns True if the visiblity state of this item is staff only (has a black sidebar) """
|
||||
return "is-staff-only" in self.q(css=self._bounded_selector(''))[0].get_attribute("class") # pylint: disable=no-member
|
||||
|
||||
def edit_name(self):
|
||||
"""
|
||||
Puts the item's name into editable form.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(self.EDIT_BUTTON_SELECTOR)).first.click() # pylint: disable=no-member
|
||||
|
||||
def enter_name(self, new_name):
|
||||
"""
|
||||
Enters new_name as the item's display name.
|
||||
"""
|
||||
set_input_value(self, self._bounded_selector(self.NAME_INPUT_SELECTOR), new_name)
|
||||
|
||||
def change_name(self, new_name):
|
||||
"""
|
||||
Changes the container's name.
|
||||
"""
|
||||
self.edit_name()
|
||||
set_input_value_and_save(self, self._bounded_selector(self.NAME_INPUT_SELECTOR), new_name)
|
||||
self.wait_for_ajax() # pylint: disable=no-member
|
||||
|
||||
def finalize_name(self):
|
||||
"""
|
||||
Presses ENTER, saving the value of the display name for this item.
|
||||
"""
|
||||
# pylint: disable=no-member
|
||||
self.q(css=self._bounded_selector(self.NAME_INPUT_SELECTOR)).results[0].send_keys(Keys.ENTER)
|
||||
self.wait_for_ajax()
|
||||
|
||||
def set_staff_lock(self, is_locked):
|
||||
"""
|
||||
Sets the explicit staff lock of item on the container page to is_locked.
|
||||
"""
|
||||
modal = self.edit()
|
||||
modal.is_explicitly_locked = is_locked
|
||||
modal.save()
|
||||
|
||||
def get_enrollment_select_options(self):
|
||||
"""
|
||||
Gets the option names available for unit group access
|
||||
"""
|
||||
modal = self.edit()
|
||||
group_options = self.q(css='.group-select-title option').text
|
||||
modal.cancel()
|
||||
return group_options
|
||||
|
||||
def toggle_unit_access(self, partition_name, group_ids):
|
||||
"""
|
||||
Toggles unit access to the groups in group_ids
|
||||
"""
|
||||
if group_ids:
|
||||
modal = self.edit()
|
||||
groups_select = self.q(css='.group-select-title select')
|
||||
select_option_by_text(groups_select, partition_name)
|
||||
|
||||
for group_id in group_ids:
|
||||
checkbox = self.q(css=u'#content-group-{group_id}'.format(group_id=group_id))
|
||||
checkbox.click()
|
||||
modal.save()
|
||||
|
||||
def in_editable_form(self):
|
||||
"""
|
||||
Return whether this outline item's display name is in its editable form.
|
||||
"""
|
||||
# pylint: disable=no-member
|
||||
return "is-editing" in self.q(
|
||||
css=self._bounded_selector(self.NAME_FIELD_WRAPPER_SELECTOR)
|
||||
)[0].get_attribute("class")
|
||||
|
||||
def edit(self):
|
||||
"""
|
||||
Puts the item into editable form.
|
||||
@@ -182,47 +65,6 @@ class CourseOutlineItem(object):
|
||||
EmptyPromise(lambda: modal.is_shown(), 'Modal is shown.') # pylint: disable=unnecessary-lambda
|
||||
return modal
|
||||
|
||||
@property
|
||||
def release_date(self):
|
||||
"""
|
||||
Returns the release date from the page. Date is "mm/dd/yyyy" string.
|
||||
"""
|
||||
element = self.q(css=self._bounded_selector(".status-release-value")) # pylint: disable=no-member
|
||||
return element.first.text[0] if element.present else None
|
||||
|
||||
@property
|
||||
def due_date(self):
|
||||
"""
|
||||
Returns the due date from the page. Date is "mm/dd/yyyy" string.
|
||||
"""
|
||||
element = self.q(css=self._bounded_selector(".status-grading-date")) # pylint: disable=no-member
|
||||
return element.first.text[0] if element.present else None
|
||||
|
||||
@property
|
||||
def policy(self):
|
||||
"""
|
||||
Select the grading format with `value` in the drop-down list.
|
||||
"""
|
||||
element = self.q(css=self._bounded_selector(".status-grading-value")) # pylint: disable=no-member
|
||||
return element.first.text[0] if element.present else None
|
||||
|
||||
@wait_for_js
|
||||
def publish(self):
|
||||
"""
|
||||
Publish the unit.
|
||||
"""
|
||||
click_css(self, self._bounded_selector('.action-publish'), require_notification=False)
|
||||
modal = CourseOutlineModal(self)
|
||||
EmptyPromise(lambda: modal.is_shown(), 'Modal is shown.') # pylint: disable=unnecessary-lambda
|
||||
modal.publish()
|
||||
|
||||
@property
|
||||
def publish_action(self):
|
||||
"""
|
||||
Returns the link for publishing a unit.
|
||||
"""
|
||||
return self.q(css=self._bounded_selector('.action-publish')).first # pylint: disable=no-member
|
||||
|
||||
|
||||
class CourseOutlineContainer(CourseOutlineItem):
|
||||
"""
|
||||
@@ -234,23 +76,6 @@ class CourseOutlineContainer(CourseOutlineItem):
|
||||
CHILD_CLASS = None
|
||||
ADD_BUTTON_SELECTOR = '> .outline-content > .add-item a.button-new'
|
||||
|
||||
def child(self, title, child_class=None):
|
||||
"""
|
||||
|
||||
:type self: object
|
||||
"""
|
||||
if not child_class:
|
||||
child_class = self.CHILD_CLASS
|
||||
|
||||
# pylint: disable=no-member
|
||||
return child_class(
|
||||
self.browser,
|
||||
self.q(css=child_class.BODY_SELECTOR).filter(
|
||||
lambda el: title in [inner.text for inner in
|
||||
el.find_elements_by_css_selector(child_class.NAME_SELECTOR)]
|
||||
).attrs('data-locator')[0]
|
||||
)
|
||||
|
||||
def children(self, child_class=None):
|
||||
"""
|
||||
Returns all the children page objects of class child_class.
|
||||
@@ -271,61 +96,6 @@ class CourseOutlineContainer(CourseOutlineItem):
|
||||
|
||||
return self.children(child_class)[index]
|
||||
|
||||
def add_child(self, require_notification=True):
|
||||
"""
|
||||
Adds a child to this xblock, waiting for notifications.
|
||||
"""
|
||||
click_css(
|
||||
self,
|
||||
self._bounded_selector(self.ADD_BUTTON_SELECTOR),
|
||||
require_notification=require_notification,
|
||||
)
|
||||
|
||||
def expand_subsection(self):
|
||||
"""
|
||||
Toggle the expansion of this subsection.
|
||||
"""
|
||||
disable_animations(self)
|
||||
|
||||
def subsection_expanded():
|
||||
"""
|
||||
Returns whether or not this subsection is expanded.
|
||||
"""
|
||||
self.wait_for_element_presence(
|
||||
self._bounded_selector(self.ADD_BUTTON_SELECTOR), 'Toggle control is present'
|
||||
)
|
||||
css_element = self._bounded_selector(self.ADD_BUTTON_SELECTOR)
|
||||
add_button = self.q(css=css_element).first.results # pylint: disable=no-member
|
||||
self.scroll_to_element(css_element) # pylint: disable=no-member
|
||||
return add_button and add_button[0].is_displayed()
|
||||
|
||||
currently_expanded = subsection_expanded()
|
||||
|
||||
# Need to click slightly off-center in order for the click to be recognized.
|
||||
css_element = self._bounded_selector('.ui-toggle-expansion .fa')
|
||||
self.scroll_to_element(css_element) # pylint: disable=no-member
|
||||
ele = self.browser.find_element_by_css_selector(css_element) # pylint: disable=no-member
|
||||
ActionChains(self.browser).move_to_element_with_offset(ele, 8, 8).click().perform() # pylint: disable=no-member
|
||||
self.wait_for_element_presence(self._bounded_selector(self.ADD_BUTTON_SELECTOR), u'Subsection is expanded')
|
||||
|
||||
EmptyPromise(
|
||||
lambda: subsection_expanded() != currently_expanded,
|
||||
u"Check that the container {} has been toggled".format(self.locator)
|
||||
).fulfill()
|
||||
|
||||
enable_animations(self)
|
||||
|
||||
return self
|
||||
|
||||
@property
|
||||
def is_collapsed(self):
|
||||
"""
|
||||
Return whether this outline item is currently collapsed.
|
||||
"""
|
||||
css_element = self._bounded_selector('')
|
||||
self.scroll_to_element(css_element) # pylint: disable=no-member
|
||||
return "is-collapsed" in self.q(css=css_element).first.attrs("class")[0] # pylint: disable=no-member
|
||||
|
||||
|
||||
class CourseOutlineChild(PageObject, CourseOutlineItem):
|
||||
"""
|
||||
@@ -341,13 +111,6 @@ class CourseOutlineChild(PageObject, CourseOutlineItem):
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='{}[data-locator="{}"]'.format(self.BODY_SELECTOR, self.locator)).present
|
||||
|
||||
def delete(self, cancel=False):
|
||||
"""
|
||||
Clicks the delete button, then cancels at the confirmation prompt if cancel is True.
|
||||
"""
|
||||
click_css(self, self._bounded_selector('.delete-button'), require_notification=False)
|
||||
confirm_prompt(self, cancel)
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular `CourseOutlineChild` context
|
||||
@@ -358,30 +121,6 @@ class CourseOutlineChild(PageObject, CourseOutlineItem):
|
||||
selector
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
titles = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).text
|
||||
if titles:
|
||||
return titles[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
"""
|
||||
Will return any first-generation descendant items of this item.
|
||||
"""
|
||||
descendants = self.q(css=self._bounded_selector(self.BODY_SELECTOR)).map(
|
||||
lambda el: CourseOutlineChild(self.browser, el.get_attribute('data-locator'))).results
|
||||
|
||||
# Now remove any non-direct descendants.
|
||||
grandkids = []
|
||||
for descendant in descendants:
|
||||
grandkids.extend(descendant.children)
|
||||
|
||||
grand_locators = [grandkid.locator for grandkid in grandkids]
|
||||
return [descendant for descendant in descendants if descendant.locator not in grand_locators]
|
||||
|
||||
|
||||
class CourseOutlineUnit(CourseOutlineChild):
|
||||
"""
|
||||
@@ -502,89 +241,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
|
||||
self.q(css='div.ui-loading.is-hidden').present
|
||||
])
|
||||
|
||||
def click_course_status_section_start_date_link(self):
|
||||
self.course_start_date_link.click()
|
||||
|
||||
def click_course_status_section_checklists_link(self):
|
||||
self.course_checklists_link.click()
|
||||
|
||||
def view_live(self):
|
||||
"""
|
||||
Clicks the "View Live" link and switches to the new tab
|
||||
"""
|
||||
click_css(self, '.view-live-button', require_notification=False)
|
||||
self.wait_for_page()
|
||||
self.browser.switch_to_window(self.browser.window_handles[-1])
|
||||
|
||||
def section(self, title):
|
||||
"""
|
||||
Return the :class:`.CourseOutlineSection` with the title `title`.
|
||||
"""
|
||||
return self.child(title)
|
||||
|
||||
def section_at(self, index):
|
||||
"""
|
||||
Returns the :class:`.CourseOutlineSection` at the specified index.
|
||||
"""
|
||||
return self.child_at(index)
|
||||
|
||||
def click_section_name(self, parent_css=''):
|
||||
"""
|
||||
Find and click on first section name in course outline
|
||||
"""
|
||||
self.q(css=u'{} .section-name'.format(parent_css)).first.click()
|
||||
|
||||
def get_section_name(self, parent_css='', page_refresh=False):
|
||||
"""
|
||||
Get the list of names of all sections present
|
||||
"""
|
||||
if page_refresh:
|
||||
self.browser.refresh()
|
||||
return self.q(css=u'{} .section-name'.format(parent_css)).text
|
||||
|
||||
def section_name_edit_form_present(self, parent_css=''):
|
||||
"""
|
||||
Check that section name edit form present
|
||||
"""
|
||||
return self.q(css=u'{} .section-name input'.format(parent_css)).present
|
||||
|
||||
def change_section_name(self, new_name, parent_css=''):
|
||||
"""
|
||||
Change section name of first section present in course outline
|
||||
"""
|
||||
self.click_section_name(parent_css)
|
||||
self.q(css=u'{} .section-name input'.format(parent_css)).first.fill(new_name)
|
||||
self.q(css=u'{} .section-name .save-button'.format(parent_css)).first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def sections(self):
|
||||
"""
|
||||
Returns the sections of this course outline page.
|
||||
"""
|
||||
return self.children()
|
||||
|
||||
def add_section_from_top_button(self):
|
||||
"""
|
||||
Clicks the button for adding a section which resides at the top of the screen.
|
||||
"""
|
||||
click_css(self, '.wrapper-mast nav.nav-actions .button-new')
|
||||
|
||||
def add_section_from_bottom_button(self, click_child_icon=False):
|
||||
"""
|
||||
Clicks the button for adding a section which resides at the bottom of the screen.
|
||||
"""
|
||||
element_css = self.BOTTOM_ADD_SECTION_BUTTON
|
||||
if click_child_icon:
|
||||
element_css += " .fa-plus"
|
||||
|
||||
click_css(self, element_css)
|
||||
|
||||
def toggle_expand_collapse(self):
|
||||
"""
|
||||
Toggles whether all sections are expanded or collapsed
|
||||
"""
|
||||
self.q(css=self.EXPAND_COLLAPSE_CSS).click()
|
||||
|
||||
def start_reindex(self):
|
||||
"""
|
||||
Starts course reindex by clicking reindex button
|
||||
@@ -598,33 +260,6 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
|
||||
self.q(css=".subsection-header-actions .configure-button").nth(index).click()
|
||||
self.wait_for_element_presence('.course-outline-modal', 'Subsection settings modal is present.')
|
||||
|
||||
def change_problem_release_date(self):
|
||||
"""
|
||||
Sets a new start date
|
||||
"""
|
||||
self.q(css=".subsection-header-actions .configure-button").first.click()
|
||||
self.q(css="#start_date").fill("01/01/2030")
|
||||
self.q(css=".action-save").first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def change_problem_due_date(self, date):
|
||||
"""
|
||||
Sets a new due date.
|
||||
|
||||
Expects date to be a string that will be accepted by the input (for example, '01/01/1970')
|
||||
"""
|
||||
self.q(css=".subsection-header-actions .configure-button").first.click()
|
||||
self.q(css="#due_date").fill(date)
|
||||
self.q(css=".action-save").first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def select_visibility_tab(self):
|
||||
"""
|
||||
Select the advanced settings tab
|
||||
"""
|
||||
self.q(css=".settings-tab-button[data-tab='visibility']").first.click()
|
||||
self.wait_for_element_presence('input[value=hide_after_due]', 'Visibility fields not present.')
|
||||
|
||||
def select_advanced_tab(self, desired_item='special_exam'):
|
||||
"""
|
||||
Select the advanced settings tab
|
||||
@@ -635,285 +270,6 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
|
||||
if desired_item == 'gated_content':
|
||||
self.wait_for_element_visibility('#is_prereq', 'Gating settings fields are present.')
|
||||
|
||||
def make_exam_proctored(self):
|
||||
"""
|
||||
Makes a Proctored exam.
|
||||
"""
|
||||
self.q(css="input.proctored_exam").first.click()
|
||||
self.q(css=".action-save").first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def make_exam_timed(self, hide_after_due=False):
|
||||
"""
|
||||
Makes a timed exam.
|
||||
"""
|
||||
self.q(css="input.timed_exam").first.click()
|
||||
if hide_after_due:
|
||||
self.select_visibility_tab()
|
||||
self.q(css='input[name=content-visibility][value=hide_after_due]').first.click()
|
||||
self.q(css=".action-save").first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def make_subsection_hidden_after_due_date(self):
|
||||
"""
|
||||
Sets a subsection to be hidden after due date.
|
||||
"""
|
||||
self.q(css='input[value=hide_after_due]').first.click()
|
||||
self.q(css=".action-save").first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def select_none_exam(self):
|
||||
"""
|
||||
Choose "none" exam but do not press enter
|
||||
"""
|
||||
self.q(css="input.no_special_exam").first.click()
|
||||
|
||||
def select_timed_exam(self):
|
||||
"""
|
||||
Choose a timed exam but do not press enter
|
||||
"""
|
||||
self.q(css="input.timed_exam").first.click()
|
||||
|
||||
def select_proctored_exam(self):
|
||||
"""
|
||||
Choose a proctored exam but do not press enter
|
||||
"""
|
||||
self.q(css="input.proctored_exam").first.click()
|
||||
|
||||
def select_practice_exam(self):
|
||||
"""
|
||||
Choose a practice exam but do not press enter
|
||||
"""
|
||||
self.q(css="input.practice_exam").first.click()
|
||||
|
||||
def time_allotted_field_visible(self):
|
||||
"""
|
||||
returns whether the time allotted field is visible
|
||||
"""
|
||||
return self.q(css=".field-time-limit").visible
|
||||
|
||||
def exam_review_rules_field_visible(self):
|
||||
"""
|
||||
Returns whether the review rules field is visible
|
||||
"""
|
||||
return self.q(css=".field-exam-review-rules").visible
|
||||
|
||||
def proctoring_items_are_displayed(self):
|
||||
"""
|
||||
Returns True if all the items are found.
|
||||
"""
|
||||
|
||||
# The None radio button
|
||||
if not self.q(css="input.no_special_exam").present:
|
||||
return False
|
||||
|
||||
# The Timed exam radio button
|
||||
if not self.q(css="input.timed_exam").present:
|
||||
return False
|
||||
|
||||
# The Proctored exam radio button
|
||||
if not self.q(css="input.proctored_exam").present:
|
||||
return False
|
||||
|
||||
# The Practice exam radio button
|
||||
if not self.q(css="input.practice_exam").present:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def make_gating_prerequisite(self):
|
||||
"""
|
||||
Makes a subsection a gating prerequisite.
|
||||
"""
|
||||
if not self.q(css="#is_prereq")[0].is_selected():
|
||||
self.q(css='label[for="is_prereq"]').click()
|
||||
self.q(css=".action-save").first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def add_prerequisite_to_subsection(self, min_score, min_completion):
|
||||
"""
|
||||
Adds a prerequisite to a subsection.
|
||||
"""
|
||||
Select(self.q(css="#prereq")[0]).select_by_index(1)
|
||||
self.q(css="#prereq_min_score").fill(min_score)
|
||||
self.q(css="#prereq_min_completion").fill(min_completion)
|
||||
self.q(css=".action-save").first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def gating_prerequisite_checkbox_is_visible(self):
|
||||
"""
|
||||
Returns True if the gating prerequisite checkbox is visible.
|
||||
"""
|
||||
|
||||
# The Prerequisite checkbox is visible
|
||||
return self.q(css="#is_prereq").visible
|
||||
|
||||
def gating_prerequisite_checkbox_is_checked(self):
|
||||
"""
|
||||
Returns True if the gating prerequisite checkbox is checked.
|
||||
"""
|
||||
|
||||
# The Prerequisite checkbox is checked
|
||||
return self.q(css="#is_prereq:checked").present
|
||||
|
||||
def gating_prerequisites_dropdown_is_visible(self):
|
||||
"""
|
||||
Returns True if the gating prerequisites dropdown is visible.
|
||||
"""
|
||||
|
||||
# The Prerequisites dropdown is visible
|
||||
return self.q(css="#prereq").visible
|
||||
|
||||
def gating_prerequisite_min_score_is_visible(self):
|
||||
"""
|
||||
Returns True if the gating prerequisite minimum score input is visible.
|
||||
"""
|
||||
|
||||
# The Prerequisites dropdown is visible
|
||||
return self.q(css="#prereq_min_score").visible
|
||||
|
||||
@property
|
||||
def has_course_status_section(self):
|
||||
# SFE and SFE-wrapper classes come from studio-frontend and
|
||||
# wrap content provided by the studio-frontend package
|
||||
return self.q(css='.course-status .SFE .SFE-wrapper').is_present()
|
||||
|
||||
@property
|
||||
def course_start_date_link(self):
|
||||
return self.q(css='.status-link').first
|
||||
|
||||
@property
|
||||
def course_checklists_link(self):
|
||||
return self.q(css='.status-link').nth(1)
|
||||
|
||||
@property
|
||||
def bottom_add_section_button(self):
|
||||
"""
|
||||
Returns the query representing the bottom add section button.
|
||||
"""
|
||||
return self.q(css=self.BOTTOM_ADD_SECTION_BUTTON).first
|
||||
|
||||
@property
|
||||
def has_no_content_message(self):
|
||||
"""
|
||||
Returns true if a message informing the user that the course has no content is visible
|
||||
"""
|
||||
return self.q(css='.outline .no-content').is_present()
|
||||
|
||||
@property
|
||||
def has_rerun_notification(self):
|
||||
"""
|
||||
Returns true iff the rerun notification is present on the page.
|
||||
"""
|
||||
return self.q(css='.wrapper-alert.is-shown').is_present()
|
||||
|
||||
def dismiss_rerun_notification(self):
|
||||
"""
|
||||
Clicks the dismiss button in the rerun notification.
|
||||
"""
|
||||
self.q(css='.dismiss-button').click()
|
||||
|
||||
@property
|
||||
def expand_collapse_link_state(self):
|
||||
"""
|
||||
Returns the current state of the expand/collapse link
|
||||
"""
|
||||
link = self.q(css=self.EXPAND_COLLAPSE_CSS)[0]
|
||||
if not link.is_displayed():
|
||||
return ExpandCollapseLinkState.MISSING
|
||||
elif "collapse-all" in link.get_attribute("class"):
|
||||
return ExpandCollapseLinkState.COLLAPSE
|
||||
else:
|
||||
return ExpandCollapseLinkState.EXPAND
|
||||
|
||||
@property
|
||||
def reindex_button(self):
|
||||
"""
|
||||
Returns reindex button.
|
||||
"""
|
||||
return self.q(css=".button.button-reindex")[0]
|
||||
|
||||
def expand_all_subsections(self):
|
||||
"""
|
||||
Expands all the subsections in this course.
|
||||
"""
|
||||
for section in self.sections():
|
||||
if section.is_collapsed:
|
||||
section.expand_subsection()
|
||||
for subsection in section.subsections():
|
||||
if subsection.is_collapsed:
|
||||
subsection.expand_subsection()
|
||||
|
||||
@property
|
||||
def xblocks(self):
|
||||
"""
|
||||
Return a list of xblocks loaded on the outline page.
|
||||
"""
|
||||
return self.children(CourseOutlineChild)
|
||||
|
||||
@property
|
||||
def license(self):
|
||||
"""
|
||||
Returns the course license text, if present. Else returns None.
|
||||
"""
|
||||
return self.q(css=".license-value").first.text[0]
|
||||
|
||||
@property
|
||||
def deprecated_warning_visible(self):
|
||||
"""
|
||||
Returns true if the deprecated warning is visible.
|
||||
"""
|
||||
return self.q(css='.wrapper-alert-error.is-shown').is_present()
|
||||
|
||||
@property
|
||||
def warning_heading_text(self):
|
||||
"""
|
||||
Returns deprecated warning heading text.
|
||||
"""
|
||||
return self.q(css='.warning-heading-text').text[0]
|
||||
|
||||
@property
|
||||
def components_list_heading(self):
|
||||
"""
|
||||
Returns deprecated warning component list heading text.
|
||||
"""
|
||||
return self.q(css='.components-list-heading-text').text[0]
|
||||
|
||||
@property
|
||||
def modules_remove_text_shown(self):
|
||||
"""
|
||||
Returns True if deprecated warning advance modules remove text is visible.
|
||||
"""
|
||||
return self.q(css='.advance-modules-remove-text').visible
|
||||
|
||||
@property
|
||||
def modules_remove_text(self):
|
||||
"""
|
||||
Returns deprecated warning advance modules remove text.
|
||||
"""
|
||||
return self.q(css='.advance-modules-remove-text').text[0]
|
||||
|
||||
@property
|
||||
def components_visible(self):
|
||||
"""
|
||||
Returns True if components list visible.
|
||||
"""
|
||||
return self.q(css='.components-list').visible
|
||||
|
||||
@property
|
||||
def components_display_names(self):
|
||||
"""
|
||||
Returns deprecated warning components display name list.
|
||||
"""
|
||||
return self.q(css='.components-list li>a').text
|
||||
|
||||
@property
|
||||
def deprecated_advance_modules(self):
|
||||
"""
|
||||
Returns deprecated advance modules list.
|
||||
"""
|
||||
return self.q(css='.advance-modules-list li').text
|
||||
|
||||
|
||||
class CourseOutlineModal(object):
|
||||
"""
|
||||
@@ -957,135 +313,6 @@ class CourseOutlineModal(object):
|
||||
self.click(".action-save")
|
||||
self.page.wait_for_ajax()
|
||||
|
||||
def publish(self):
|
||||
"""
|
||||
Click the publish action button, and wait for the ajax call to return.
|
||||
"""
|
||||
self.click(".action-publish")
|
||||
self.page.wait_for_ajax()
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Click the cancel action button.
|
||||
"""
|
||||
self.click(".action-cancel")
|
||||
|
||||
def has_release_date(self):
|
||||
"""
|
||||
Check if the input box for the release date exists in the subsection's settings window
|
||||
"""
|
||||
return self.find_css("#start_date").present
|
||||
|
||||
def has_release_time(self):
|
||||
"""
|
||||
Check if the input box for the release time exists in the subsection's settings window
|
||||
"""
|
||||
return self.find_css("#start_time").present
|
||||
|
||||
def has_due_date(self):
|
||||
"""
|
||||
Check if the input box for the due date exists in the subsection's settings window
|
||||
"""
|
||||
return self.find_css("#due_date").present
|
||||
|
||||
def has_due_time(self):
|
||||
"""
|
||||
Check if the input box for the due time exists in the subsection's settings window
|
||||
"""
|
||||
return self.find_css("#due_time").present
|
||||
|
||||
def has_policy(self):
|
||||
"""
|
||||
Check if the input for the grading policy is present.
|
||||
"""
|
||||
return self.find_css("#grading_type").present
|
||||
|
||||
def set_date(self, property_name, input_selector, date):
|
||||
"""
|
||||
Set `date` value to input pointed by `selector` and `property_name`.
|
||||
"""
|
||||
month, day, year = list(map(int, date.split('/')))
|
||||
self.click(input_selector)
|
||||
if getattr(self, property_name):
|
||||
current_month, current_year = list(map(int, getattr(self, property_name).split('/')[1:]))
|
||||
else: # Use default timepicker values, which are current month and year.
|
||||
current_month, current_year = datetime.datetime.today().month, datetime.datetime.today().year
|
||||
date_diff = 12 * (year - current_year) + month - current_month
|
||||
selector = u"a.ui-datepicker-{}".format('next' if date_diff > 0 else 'prev')
|
||||
for __ in range(abs(date_diff)):
|
||||
self.page.q(css=selector).click()
|
||||
self.page.q(css="a.ui-state-default").nth(day - 1).click() # set day
|
||||
self.page.wait_for_element_invisibility("#ui-datepicker-div", "datepicker should be closed")
|
||||
EmptyPromise(
|
||||
lambda: getattr(self, property_name) == u'{m}/{d}/{y}'.format(m=month, d=day, y=year),
|
||||
u"{} is updated in modal.".format(property_name)
|
||||
).fulfill()
|
||||
|
||||
def set_time(self, input_selector, time):
|
||||
"""
|
||||
Set `time` value to input pointed by `input_selector`
|
||||
Not using the time picker to make sure it's not being rounded up
|
||||
"""
|
||||
|
||||
self.page.q(css=input_selector).fill(time)
|
||||
self.page.q(css=input_selector).results[0].send_keys(Keys.ENTER)
|
||||
|
||||
@property
|
||||
def release_date(self):
|
||||
"""
|
||||
Returns the unit's release date. Date is "mm/dd/yyyy" string.
|
||||
"""
|
||||
return self.find_css("#start_date").first.attrs('value')[0]
|
||||
|
||||
@release_date.setter
|
||||
def release_date(self, date):
|
||||
"""
|
||||
Sets the unit's release date to `date`. Date is "mm/dd/yyyy" string.
|
||||
"""
|
||||
self.set_date('release_date', "#start_date", date)
|
||||
|
||||
@property
|
||||
def release_time(self):
|
||||
"""
|
||||
Returns the current value of the release time. Default is u'00:00'
|
||||
"""
|
||||
return self.find_css("#start_time").first.attrs('value')[0]
|
||||
|
||||
@release_time.setter
|
||||
def release_time(self, time):
|
||||
"""
|
||||
Time is "HH:MM" string.
|
||||
"""
|
||||
self.set_time("#start_time", time)
|
||||
|
||||
@property
|
||||
def due_date(self):
|
||||
"""
|
||||
Returns the due date from the page. Date is "mm/dd/yyyy" string.
|
||||
"""
|
||||
return self.find_css("#due_date").first.attrs('value')[0]
|
||||
|
||||
@due_date.setter
|
||||
def due_date(self, date):
|
||||
"""
|
||||
Sets the due date for the unit. Date is "mm/dd/yyyy" string.
|
||||
"""
|
||||
self.set_date('due_date', "#due_date", date)
|
||||
|
||||
@property
|
||||
def due_time(self):
|
||||
"""
|
||||
Returns the current value of the release time. Default is u''
|
||||
"""
|
||||
return self.find_css("#due_time").first.attrs('value')[0]
|
||||
|
||||
@due_time.setter
|
||||
def due_time(self, time):
|
||||
"""
|
||||
Time is "HH:MM" string.
|
||||
"""
|
||||
self.set_time("#due_time", time)
|
||||
|
||||
@property
|
||||
def policy(self):
|
||||
"""
|
||||
@@ -1108,49 +335,6 @@ class CourseOutlineModal(object):
|
||||
"Grading label is updated.",
|
||||
).fulfill()
|
||||
|
||||
@property
|
||||
def is_staff_lock_visible(self):
|
||||
"""
|
||||
Returns True if the staff lock option is visible.
|
||||
"""
|
||||
return self.find_css('#staff_lock').visible
|
||||
|
||||
def ensure_staff_lock_visible(self):
|
||||
"""
|
||||
Ensures the staff lock option is visible, clicking on the advanced tab
|
||||
if needed.
|
||||
"""
|
||||
if not self.is_staff_lock_visible:
|
||||
self.find_css(".settings-tab-button[data-tab=visibility]").click()
|
||||
EmptyPromise(
|
||||
lambda: self.is_staff_lock_visible,
|
||||
"Staff lock option is visible",
|
||||
).fulfill()
|
||||
|
||||
@property
|
||||
def is_explicitly_locked(self):
|
||||
"""
|
||||
Returns true if the explict staff lock checkbox is checked, false otherwise.
|
||||
"""
|
||||
self.ensure_staff_lock_visible()
|
||||
return self.find_css('#staff_lock')[0].is_selected()
|
||||
|
||||
@is_explicitly_locked.setter
|
||||
def is_explicitly_locked(self, value):
|
||||
"""
|
||||
Checks the explicit staff lock box if value is true, otherwise selects "visible".
|
||||
"""
|
||||
self.ensure_staff_lock_visible()
|
||||
if value != self.is_explicitly_locked:
|
||||
self.find_css('#staff_lock').click()
|
||||
EmptyPromise(lambda: value == self.is_explicitly_locked, "Explicit staff lock is updated").fulfill()
|
||||
|
||||
def shows_staff_lock_warning(self):
|
||||
"""
|
||||
Returns true iff the staff lock warning is visible.
|
||||
"""
|
||||
return self.find_css('.staff-lock .tip-warning').visible
|
||||
|
||||
def get_selected_option_text(self, element):
|
||||
"""
|
||||
Returns the text of the first selected option for the element.
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
Studio Problem Editor
|
||||
"""
|
||||
|
||||
|
||||
from selenium.webdriver.support.ui import Select
|
||||
from six.moves import range
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css
|
||||
from common.test.acceptance.pages.studio.xblock_editor import XBlockEditorView
|
||||
|
||||
|
||||
class ProblemXBlockEditorView(XBlockEditorView):
|
||||
"""
|
||||
Represents the rendered view of a Problem editor.
|
||||
"""
|
||||
|
||||
editor_mode_css = '.edit-xblock-modal .editor-modes .editor-button'
|
||||
settings_mode = '.settings-button'
|
||||
|
||||
def open_settings(self):
|
||||
"""
|
||||
Clicks on the settings button
|
||||
"""
|
||||
click_css(self, self.settings_mode)
|
||||
|
||||
def set_field_val(self, field_display_name, field_value):
|
||||
"""
|
||||
If editing, set the value of a field.
|
||||
"""
|
||||
selector = u'.metadata_edit li.field label:contains("{}") + input'.format(field_display_name)
|
||||
script = "$(arguments[0]).val(arguments[1]).change();"
|
||||
self.browser.execute_script(script, selector, field_value)
|
||||
|
||||
def get_field_val(self, field_display_name):
|
||||
"""
|
||||
If editing, get the value of field
|
||||
|
||||
Args:
|
||||
field_display_name(str): Name of the field for which the value is required
|
||||
Returns:
|
||||
(string): Value of the field
|
||||
"""
|
||||
script = u"return $('.wrapper-comp-setting label:contains({}) + input').val();".format(field_display_name)
|
||||
return self.browser.execute_script(script)
|
||||
|
||||
def get_default_dropdown_value(self, css):
|
||||
"""
|
||||
Gets default value from the dropdown
|
||||
Args:
|
||||
css(string): css of the dropdown for which default value is required
|
||||
Returns:
|
||||
value(string): Default dropdown value
|
||||
"""
|
||||
element = self.browser.find_element_by_css_selector(css)
|
||||
dropdown_default_selection = Select(element)
|
||||
value = dropdown_default_selection.first_selected_option.text
|
||||
return value
|
||||
|
||||
def select_from_dropdown(self, dropdown_name, value):
|
||||
"""
|
||||
Selects from the dropdown
|
||||
Arguments:
|
||||
dropdown_name(string): Name of the dropdown to be opened
|
||||
value(string): Value to be selected
|
||||
"""
|
||||
self.q(css=u'select[class="input setting-input"][name="{}"]'.format(dropdown_name)).first.click()
|
||||
self.wait_for_element_visibility(u'option[value="{}"]'.format(value), 'Dropdown is visible')
|
||||
self.q(css=u'option[value="{}"]'.format(value)).click()
|
||||
|
||||
def get_value_from_the_dropdown(self, dropdown_name):
|
||||
"""
|
||||
Get selected value from the dropdown
|
||||
Args:
|
||||
dropdown_name(string): Name of the dropdown
|
||||
Returns:
|
||||
(string): Selected Value from the dropdown
|
||||
|
||||
"""
|
||||
dropdown = self.browser.find_element_by_css_selector(
|
||||
u'select[class="input setting-input"][name="{}"]'.format(dropdown_name)
|
||||
)
|
||||
return Select(dropdown).first_selected_option.text
|
||||
|
||||
def get_settings(self):
|
||||
"""
|
||||
Default settings of problem
|
||||
Returns:
|
||||
settings_dict(dictionary): A dictionary of all the default settings
|
||||
"""
|
||||
settings_dict = {}
|
||||
number_of_settings = len(self.q(css='.wrapper-comp-setting'))
|
||||
css = u'.list-input.settings-list .field.comp-setting-entry:nth-of-type({}) {}'
|
||||
|
||||
for index in range(1, number_of_settings + 1):
|
||||
key = self.q(css=css.format(index, "label")).text[0]
|
||||
if self.q(css=css.format(index, "input")).present:
|
||||
value = self.q(css=css.format(index, "input")).attrs('value')[0]
|
||||
elif self.q(css=css.format(index, "select")).present:
|
||||
value = self.get_default_dropdown_value(css.format(index, "select"))
|
||||
settings_dict[key] = value
|
||||
|
||||
return settings_dict
|
||||
|
||||
def revert_setting(self, display_name=False):
|
||||
"""
|
||||
Click to revert setting to default
|
||||
"""
|
||||
if display_name:
|
||||
self.q(css='.action.setting-clear.active').first.click()
|
||||
else:
|
||||
self.q(css='.action.setting-clear.active').results[1].click()
|
||||
|
||||
def toggle_cheatsheet(self):
|
||||
"""
|
||||
Toggle cheatsheet on toolbar
|
||||
"""
|
||||
self.q(css='.cheatsheet-toggle').first.click()
|
||||
self.wait_for_element_visibility('.simple-editor-cheatsheet.shown', 'Cheatsheet is visible')
|
||||
|
||||
def is_cheatsheet_present(self):
|
||||
"""
|
||||
Check for cheatsheet presence
|
||||
Returns:
|
||||
bool: True if present
|
||||
"""
|
||||
return self.q(css='.simple-editor-cheatsheet.shown').present
|
||||
|
||||
def is_latex_compiler_present(self):
|
||||
"""
|
||||
Checks for the presence of latex compiler settings
|
||||
Returns:
|
||||
bool: True if present
|
||||
"""
|
||||
return self.q(css='.launch-latex-compiler').present
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
click_css(self, '.action-save')
|
||||
@@ -4,16 +4,10 @@ Course Schedule and Details Settings page.
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
import os.path
|
||||
|
||||
import six
|
||||
from bok_choy.javascript import requirejs
|
||||
from bok_choy.promise import EmptyPromise
|
||||
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
from common.test.acceptance.pages.studio.users import wait_for_ajax_or_reload
|
||||
from common.test.acceptance.pages.studio.utils import press_the_notification_button, type_in_codemirror
|
||||
|
||||
|
||||
@requirejs('js/factories/settings')
|
||||
@@ -33,324 +27,3 @@ class SettingsPage(CoursePage):
|
||||
def is_browser_on_page(self):
|
||||
wait_for_ajax_or_reload(self.browser)
|
||||
return self.q(css='body.view-settings').visible
|
||||
|
||||
def wait_for_require_js(self):
|
||||
"""
|
||||
Wait for require-js to load javascript files.
|
||||
"""
|
||||
if hasattr(self, 'wait_for_js'):
|
||||
self.wait_for_js() # pylint: disable=no-member
|
||||
|
||||
def wait_for_jquery_value(self, jquery_element, value):
|
||||
"""
|
||||
Use jQuery to obtain the element's value.
|
||||
This is useful for when jQuery performs functions towards the
|
||||
end of the page load. (In other words, waiting for jquery to
|
||||
load is not enough; we need to also query values that it has
|
||||
injected onto the page to ensure it's done.)
|
||||
"""
|
||||
self.wait_for(
|
||||
lambda: self.browser.execute_script(
|
||||
"return $('{ele}').val();".format(ele=jquery_element)) == '{val}'.format(val=value),
|
||||
'wait for jQuery to finish loading data on page.'
|
||||
)
|
||||
|
||||
def refresh_and_wait_for_load(self):
|
||||
"""
|
||||
Refresh the page and wait for all resources to load.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
self.wait_for_page()
|
||||
|
||||
def get_elements(self, css_selector):
|
||||
self.wait_for_element_presence(
|
||||
css_selector,
|
||||
'Elements matching "{}" selector are present'.format(css_selector)
|
||||
)
|
||||
results = self.q(css=css_selector)
|
||||
return results
|
||||
|
||||
def get_element(self, css_selector):
|
||||
results = self.get_elements(css_selector=css_selector)
|
||||
return results[0] if results else None
|
||||
|
||||
def set_element_values(self, element_values):
|
||||
"""
|
||||
Set the values of the elements to those specified
|
||||
in the element_values dict.
|
||||
"""
|
||||
for css, value in six.iteritems(element_values):
|
||||
element = self.get_element(css)
|
||||
element.clear()
|
||||
element.send_keys(value)
|
||||
|
||||
def un_focus_input_field(self):
|
||||
"""
|
||||
Makes an input field un-focus by
|
||||
clicking outside of it.
|
||||
"""
|
||||
self.get_element('.title-2').click()
|
||||
|
||||
def is_element_present(self, css_selector):
|
||||
"""
|
||||
Returns boolean based on the presence
|
||||
of an element with css as passed.
|
||||
"""
|
||||
return self.q(css=css_selector).present
|
||||
|
||||
def change_course_description(self, change_text):
|
||||
"""
|
||||
Changes the course description
|
||||
"""
|
||||
type_in_codemirror(self, 0, change_text, find_prefix="$")
|
||||
|
||||
################
|
||||
# Properties
|
||||
################
|
||||
@property
|
||||
def pre_requisite_course_options(self):
|
||||
"""
|
||||
Returns the pre-requisite course drop down field options.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'#pre-requisite-course',
|
||||
'Prerequisite course element is available'
|
||||
)
|
||||
return self.get_elements('#pre-requisite-course')
|
||||
|
||||
@property
|
||||
def entrance_exam_field(self):
|
||||
"""
|
||||
Returns the enable entrance exam checkbox.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'#entrance-exam-enabled',
|
||||
'Entrance exam checkbox is available'
|
||||
)
|
||||
return self.get_element('#entrance-exam-enabled')
|
||||
|
||||
@property
|
||||
def alert_confirmation_title(self):
|
||||
"""
|
||||
Returns the alert confirmation element, which contains text
|
||||
such as 'Your changes have been saved.'
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'#alert-confirmation-title',
|
||||
'Alert confirmation title element is available'
|
||||
)
|
||||
return self.get_element('#alert-confirmation-title')
|
||||
|
||||
@property
|
||||
def course_license(self):
|
||||
"""
|
||||
Property. Returns the text of the license type for the course
|
||||
("All Rights Reserved" or "Creative Commons")
|
||||
"""
|
||||
license_types_css = ".license ul.license-types li.license-type"
|
||||
self.wait_for_element_presence(
|
||||
license_types_css,
|
||||
"license type buttons are present",
|
||||
)
|
||||
selected = self.q(css=license_types_css + " button.is-selected")
|
||||
if selected.is_present():
|
||||
return selected.text[0]
|
||||
|
||||
# Look for the license text that will be displayed by default,
|
||||
# if no button is yet explicitly selected
|
||||
license_text = self.q(css='.license span.license-text')
|
||||
if license_text.is_present():
|
||||
return license_text.text[0]
|
||||
return None
|
||||
|
||||
@course_license.setter
|
||||
def course_license(self, license_name):
|
||||
"""
|
||||
Sets the course license to the given license_name
|
||||
(str, "All Rights Reserved" or "Creative Commons")
|
||||
"""
|
||||
license_types_css = ".license ul.license-types li.license-type"
|
||||
self.wait_for_element_presence(
|
||||
license_types_css,
|
||||
"license type buttons are present",
|
||||
)
|
||||
button_xpath = (
|
||||
"//div[contains(@class, 'license')]"
|
||||
"//ul[contains(@class, 'license-types')]"
|
||||
"//li[contains(@class, 'license-type')]"
|
||||
"//button[contains(text(),'{license_name}')]"
|
||||
).format(license_name=license_name)
|
||||
button = self.q(xpath=button_xpath)
|
||||
if not button.present:
|
||||
raise Exception("Invalid license name: {name}".format(name=license_name))
|
||||
button.click()
|
||||
|
||||
pacing_css = '.pacing input[type=radio]'
|
||||
|
||||
@property
|
||||
def checked_pacing_css(self):
|
||||
"""CSS for the course pacing button which is currently checked."""
|
||||
return self.pacing_css + ':checked'
|
||||
|
||||
@property
|
||||
def course_pacing(self):
|
||||
"""
|
||||
Returns the label text corresponding to the checked pacing radio button.
|
||||
"""
|
||||
self.wait_for_element_presence(self.checked_pacing_css, 'course pacing controls present and rendered')
|
||||
checked = self.q(css=self.checked_pacing_css).results[0]
|
||||
checked_id = checked.get_attribute('id')
|
||||
return self.q(css='label[for={checked_id}]'.format(checked_id=checked_id)).results[0].text
|
||||
|
||||
@course_pacing.setter
|
||||
def course_pacing(self, pacing):
|
||||
"""
|
||||
Sets the course to either self-paced or instructor-paced by checking
|
||||
the appropriate radio button.
|
||||
"""
|
||||
self.wait_for_element_presence(self.checked_pacing_css, 'course pacing controls present')
|
||||
self.q(xpath="//label[contains(text(), '{pacing}')]".format(pacing=pacing)).click()
|
||||
|
||||
@property
|
||||
def course_pacing_disabled_text(self):
|
||||
"""
|
||||
Return the message indicating that course pacing cannot be toggled.
|
||||
"""
|
||||
return self.q(css='#course-pace-toggle-tip').results[0].text
|
||||
|
||||
def course_pacing_disabled(self):
|
||||
"""
|
||||
Return True if the course pacing controls are disabled; False otherwise.
|
||||
"""
|
||||
self.wait_for_element_presence(self.checked_pacing_css, 'course pacing controls present')
|
||||
statuses = self.q(css=self.pacing_css).map(lambda e: e.get_attribute('disabled')).results
|
||||
return all((s == 'true' for s in statuses))
|
||||
|
||||
################
|
||||
# Waits
|
||||
################
|
||||
def wait_for_prerequisite_course_options(self):
|
||||
"""
|
||||
Ensure the pre_requisite_course_options dropdown selector is displayed
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.q(css="#pre-requisite-course").present,
|
||||
'Prerequisite course dropdown selector is displayed'
|
||||
).fulfill()
|
||||
|
||||
################
|
||||
# Clicks
|
||||
################
|
||||
|
||||
def click_button(self, name):
|
||||
"""
|
||||
Clicks the button
|
||||
"""
|
||||
btn_css = 'div#page-notification button.action-{}'.format(name.lower())
|
||||
EmptyPromise(
|
||||
lambda: self.q(css=btn_css).visible,
|
||||
'{} button is visible'.format(name)
|
||||
).fulfill()
|
||||
press_the_notification_button(self, name)
|
||||
|
||||
################
|
||||
# Workflows
|
||||
################
|
||||
|
||||
def require_entrance_exam(self, required=True):
|
||||
"""
|
||||
Set the entrance exam requirement via the checkbox.
|
||||
"""
|
||||
checkbox = self.entrance_exam_field
|
||||
# Wait for license section to load before interacting with the checkbox to avoid race condition
|
||||
self.wait_for_element_presence('div.wrapper-license', 'License section present')
|
||||
selected = checkbox.is_selected()
|
||||
self.scroll_to_element('#entrance-exam-enabled')
|
||||
if required and not selected:
|
||||
checkbox.click()
|
||||
self.wait_for_element_presence(
|
||||
'#entrance-exam-minimum-score-pct',
|
||||
'Entrance exam minimum score percent is present'
|
||||
)
|
||||
if not required and selected:
|
||||
checkbox.click()
|
||||
self.wait_for_element_absence(
|
||||
'#entrance-exam-minimum-score-pct',
|
||||
'Entrance exam minimum score percent is absent'
|
||||
)
|
||||
|
||||
def save_changes(self, wait_for_confirmation=True):
|
||||
"""
|
||||
Clicks save button, waits for confirmation unless otherwise specified
|
||||
"""
|
||||
press_the_notification_button(self, "save")
|
||||
if wait_for_confirmation:
|
||||
self.wait_for_element_visibility(
|
||||
'#alert-confirmation-title',
|
||||
'Save confirmation message is visible'
|
||||
)
|
||||
# After visibility an ajax call is in process, waiting for that to complete
|
||||
self.wait_for_ajax()
|
||||
|
||||
def refresh_page(self, wait_for_confirmation=True):
|
||||
"""
|
||||
Reload the page.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
if wait_for_confirmation:
|
||||
EmptyPromise(
|
||||
lambda: self.q(css='body.view-settings').present,
|
||||
'Page is refreshed'
|
||||
).fulfill()
|
||||
self.wait_for_require_js()
|
||||
self.wait_for_ajax()
|
||||
|
||||
@staticmethod
|
||||
def get_asset_path(file_name):
|
||||
"""
|
||||
Returns the full path of the file to upload.
|
||||
These files have been placed in edx-platform/common/test/data/uploads/
|
||||
"""
|
||||
|
||||
# Separate the list of folders in the path reaching to the current file,
|
||||
# e.g. '... common/test/acceptance/pages/lms/instructor_dashboard.py' will result in
|
||||
# [..., 'common', 'test', 'acceptance', 'pages', 'lms', 'instructor_dashboard.py']
|
||||
folders_list_in_path = os.path.abspath(__file__).split(os.sep)
|
||||
|
||||
# Get rid of the last 4 elements: 'acceptance', 'pages', 'lms', and 'instructor_dashboard.py'
|
||||
# to point to the 'test' folder, a shared point in the path's tree.
|
||||
folders_list_in_path = folders_list_in_path[:-4]
|
||||
|
||||
# Append the folders in the asset's path
|
||||
folders_list_in_path.extend(['data', 'uploads', file_name])
|
||||
|
||||
# Return the joined path of the required asset.
|
||||
return os.sep.join(folders_list_in_path)
|
||||
|
||||
def upload_image(self, upload_btn_selector, file_to_upload):
|
||||
"""
|
||||
Upload image specified by image_selector and file_to_upload
|
||||
"""
|
||||
|
||||
# wait for upload button
|
||||
self.wait_for_element_visibility(upload_btn_selector, 'upload button is present')
|
||||
|
||||
self.q(css=upload_btn_selector).results[0].click()
|
||||
|
||||
# wait for popup
|
||||
self.wait_for_element_presence(self.upload_image_popup_window_selector, 'upload dialog is present')
|
||||
|
||||
# upload image
|
||||
filepath = SettingsPage.get_asset_path(file_to_upload)
|
||||
self.q(css=self.upload_image_browse_button_selector).results[0].send_keys(filepath)
|
||||
self.q(css=self.upload_image_upload_button_selector).results[0].click()
|
||||
|
||||
# wait for popup closed
|
||||
self.wait_for_element_absence(self.upload_image_popup_window_selector, 'upload dialog is hidden')
|
||||
|
||||
def get_uploaded_image_path(self, image_selector):
|
||||
"""
|
||||
Returns the uploaded image path
|
||||
"""
|
||||
|
||||
return self.q(css=image_selector).attrs('src')[0]
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
"""
|
||||
Course Advanced Settings page
|
||||
"""
|
||||
|
||||
|
||||
import six
|
||||
from bok_choy.promise import EmptyPromise
|
||||
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
from common.test.acceptance.pages.studio.utils import (
|
||||
get_codemirror_value,
|
||||
press_the_notification_button,
|
||||
type_in_codemirror
|
||||
)
|
||||
|
||||
KEY_CSS = '.key h3.title'
|
||||
UNDO_BUTTON_SELECTOR = ".action-item .action-undo"
|
||||
MANUAL_BUTTON_SELECTOR = ".action-item .action-cancel"
|
||||
MODAL_SELECTOR = ".validation-error-modal-content"
|
||||
ERROR_ITEM_NAME_SELECTOR = ".error-item-title strong"
|
||||
ERROR_ITEM_CONTENT_SELECTOR = ".error-item-message"
|
||||
SETTINGS_NAME_SELECTOR = ".is-not-editable"
|
||||
CONFIRMATION_MESSAGE_SELECTOR = "#alert-confirmation-title"
|
||||
DEPRECATED_SETTINGS_SELECTOR = ".field-group.course-advanced-policy-list-item.is-deprecated"
|
||||
DEPRECATED_SETTINGS_BUTTON_SELECTOR = ".deprecated-settings-label"
|
||||
|
||||
|
||||
class AdvancedSettingsPage(CoursePage):
|
||||
"""
|
||||
Course Advanced Settings page.
|
||||
"""
|
||||
|
||||
url_path = "settings/advanced"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
def _is_finished_loading():
|
||||
return len(self.q(css='.course-advanced-policy-list-item')) > 0
|
||||
|
||||
EmptyPromise(_is_finished_loading, 'Finished rendering the advanced policy items.').fulfill()
|
||||
return self.q(css='body.advanced').present
|
||||
|
||||
@property
|
||||
def key_names(self):
|
||||
"""
|
||||
Returns a list of key names of all settings.
|
||||
"""
|
||||
return self.q(css=KEY_CSS).text
|
||||
|
||||
@property
|
||||
def deprecated_settings_button_text(self):
|
||||
"""
|
||||
Returns text for deprecated settings button
|
||||
"""
|
||||
return self.q(css=DEPRECATED_SETTINGS_BUTTON_SELECTOR).text[0]
|
||||
|
||||
def is_deprecated_setting_visible(self):
|
||||
"""
|
||||
Returns true if deprecated settings are visible
|
||||
"""
|
||||
return self.q(css=DEPRECATED_SETTINGS_SELECTOR).visible
|
||||
|
||||
def toggle_deprecated_settings(self):
|
||||
"""
|
||||
Show deprecated Settings
|
||||
"""
|
||||
button_text = self.deprecated_settings_button_text
|
||||
self.q(css=DEPRECATED_SETTINGS_BUTTON_SELECTOR).click()
|
||||
if button_text == 'Show Deprecated Settings':
|
||||
self.wait_for_element_presence(DEPRECATED_SETTINGS_SELECTOR, 'Deprecated Settings are present')
|
||||
else:
|
||||
self.wait_for_element_absence(DEPRECATED_SETTINGS_SELECTOR, 'Deprecated Settings are not present')
|
||||
|
||||
def wait_for_modal_load(self):
|
||||
"""
|
||||
Wait for validation response from the server, and make sure that
|
||||
the validation error modal pops up.
|
||||
|
||||
This method should only be called when it is guaranteed that there're
|
||||
validation errors in the settings changes.
|
||||
"""
|
||||
self.wait_for_ajax()
|
||||
self.wait_for_element_presence(MODAL_SELECTOR, 'Validation Modal is present')
|
||||
|
||||
def refresh_and_wait_for_load(self):
|
||||
"""
|
||||
Refresh the page and wait for all resources to load.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
self.wait_for_page()
|
||||
|
||||
@property
|
||||
def confirmation_message(self):
|
||||
"""
|
||||
Returns the text of confirmation message which appears after saving the settings
|
||||
"""
|
||||
self.wait_for_element_visibility(CONFIRMATION_MESSAGE_SELECTOR, 'Confirmation message is visible')
|
||||
return self.q(css=CONFIRMATION_MESSAGE_SELECTOR).text[0]
|
||||
|
||||
def coordinates_for_scrolling(self, coordinates_for):
|
||||
"""
|
||||
Get the x and y coordinates of elements
|
||||
"""
|
||||
cordinates_dict = self.browser.find_element_by_css_selector(coordinates_for)
|
||||
location = cordinates_dict.location
|
||||
for key, val in six.iteritems(location):
|
||||
if key == 'x':
|
||||
x_axis = val
|
||||
elif key == 'y':
|
||||
y_axis = val
|
||||
return x_axis, y_axis
|
||||
|
||||
def undo_changes_via_modal(self):
|
||||
"""
|
||||
Trigger clicking event of the undo changes button in the modal.
|
||||
Wait for the undoing process to load via ajax call.
|
||||
Before that Scroll so the button is clickable on all browsers
|
||||
"""
|
||||
self.browser.execute_script("window.scrollTo" + str(self.coordinates_for_scrolling(UNDO_BUTTON_SELECTOR)))
|
||||
self.q(css=UNDO_BUTTON_SELECTOR).click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def trigger_manual_changes(self):
|
||||
"""
|
||||
Trigger click event of the manual changes button in the modal.
|
||||
No need to wait for any ajax.
|
||||
Before that Scroll so the button is clickable on all browsers
|
||||
"""
|
||||
self.browser.execute_script("window.scrollTo" + str(self.coordinates_for_scrolling(MANUAL_BUTTON_SELECTOR)))
|
||||
self.q(css=MANUAL_BUTTON_SELECTOR).click()
|
||||
|
||||
def is_validation_modal_present(self):
|
||||
"""
|
||||
Checks if the validation modal is present.
|
||||
"""
|
||||
return self.q(css=MODAL_SELECTOR).present
|
||||
|
||||
def get_error_item_names(self):
|
||||
"""
|
||||
Returns a list of display names of all invalid settings.
|
||||
"""
|
||||
return self.q(css=ERROR_ITEM_NAME_SELECTOR).text
|
||||
|
||||
def get_error_item_messages(self):
|
||||
"""
|
||||
Returns a list of error messages of all invalid settings.
|
||||
"""
|
||||
return self.q(css=ERROR_ITEM_CONTENT_SELECTOR).text
|
||||
|
||||
def _get_index_of(self, expected_key):
|
||||
"""
|
||||
Returns the index of expected key
|
||||
"""
|
||||
for i, element in enumerate(self.q(css=KEY_CSS)):
|
||||
# Sometimes get stale reference if I hold on to the array of elements
|
||||
key = self.q(css=KEY_CSS).nth(i).text[0]
|
||||
if key == expected_key:
|
||||
return i
|
||||
|
||||
return -1
|
||||
|
||||
def save(self):
|
||||
press_the_notification_button(self, "Save")
|
||||
|
||||
def cancel(self):
|
||||
press_the_notification_button(self, "Cancel")
|
||||
|
||||
def set(self, key, new_value):
|
||||
index = self._get_index_of(key)
|
||||
type_in_codemirror(self, index, new_value)
|
||||
self.save()
|
||||
|
||||
def get(self, key):
|
||||
index = self._get_index_of(key)
|
||||
return get_codemirror_value(self, index)
|
||||
|
||||
def set_values(self, key_value_map):
|
||||
"""
|
||||
Make multiple settings changes and save them.
|
||||
"""
|
||||
for key, value in six.iteritems(key_value_map):
|
||||
index = self._get_index_of(key)
|
||||
type_in_codemirror(self, index, value)
|
||||
|
||||
self.save()
|
||||
|
||||
def get_values(self, key_list):
|
||||
"""
|
||||
Get a key-value dictionary of all keys in the given list.
|
||||
"""
|
||||
result_map = {}
|
||||
|
||||
for key in key_list:
|
||||
index = self._get_index_of(key)
|
||||
val = get_codemirror_value(self, index)
|
||||
result_map[key] = val
|
||||
|
||||
return result_map
|
||||
|
||||
@property
|
||||
def displayed_settings_names(self):
|
||||
"""
|
||||
Returns all settings displayed on the advanced settings page/screen/modal/whatever
|
||||
We call it 'name', but it's really whatever is embedded in the 'id' element for each field
|
||||
"""
|
||||
query = self.q(css=SETTINGS_NAME_SELECTOR)
|
||||
return query.attrs('id')
|
||||
|
||||
@property
|
||||
def expected_settings_names(self):
|
||||
"""
|
||||
Returns a list of settings expected to be displayed on the Advanced Settings screen
|
||||
Should match the list of settings found in cms/djangoapps/models/settings/course_metadata.py
|
||||
If a new setting is added to the metadata list, this test will fail and you must update it.
|
||||
Basically this guards against accidental exposure of a field on the Advanced Settings screen
|
||||
"""
|
||||
return [
|
||||
'advanced_modules',
|
||||
'allow_anonymous',
|
||||
'allow_anonymous_to_peers',
|
||||
'allow_public_wiki_access',
|
||||
'cert_html_view_overrides',
|
||||
'cert_name_long',
|
||||
'cert_name_short',
|
||||
'certificates_display_behavior',
|
||||
'course_image',
|
||||
'banner_image',
|
||||
'video_thumbnail_image',
|
||||
'cosmetic_display_price',
|
||||
'advertised_start',
|
||||
'announcement',
|
||||
'display_name',
|
||||
'is_new',
|
||||
'issue_badges',
|
||||
'max_student_enrollments_allowed',
|
||||
'no_grade',
|
||||
'display_coursenumber',
|
||||
'display_organization',
|
||||
'catalog_visibility',
|
||||
'days_early_for_beta',
|
||||
'disable_progress_graph',
|
||||
'discussion_blackouts',
|
||||
'discussion_sort_alpha',
|
||||
'discussion_topics',
|
||||
'due',
|
||||
'due_date_display_format',
|
||||
'edxnotes',
|
||||
'use_latex_compiler',
|
||||
'video_speed_optimizations',
|
||||
'enrollment_domain',
|
||||
'html_textbooks',
|
||||
'invitation_only',
|
||||
'lti_passports',
|
||||
'matlab_api_key',
|
||||
'max_attempts',
|
||||
'mobile_available',
|
||||
'rerandomize',
|
||||
'remote_gradebook',
|
||||
'showanswer',
|
||||
'show_calculator',
|
||||
'show_reset_button',
|
||||
'static_asset_path',
|
||||
'teams_configuration',
|
||||
'social_sharing_url',
|
||||
'video_bumper',
|
||||
'enable_proctored_exams',
|
||||
'allow_proctoring_opt_out',
|
||||
'enable_timed_exams',
|
||||
'enable_subsection_gating',
|
||||
'learning_info',
|
||||
'instructor_info',
|
||||
'ccx_connector',
|
||||
'enable_ccx',
|
||||
]
|
||||
@@ -1,655 +0,0 @@
|
||||
"""
|
||||
Course Certificates page objects.
|
||||
The methods in these classes are organized into several conceptual buckets:
|
||||
* Helpers: General utility methods used throughout, such as css selection helpers
|
||||
* Properties: Specific page/object field getters/setters (mainly for form inputs)
|
||||
* Wait Actions: EmptyPromises used to ensure element availabilty prior to performing an action
|
||||
* Click Actions: Specific element invocations -- mainly links/buttons but anything clickable
|
||||
* Workflows: Complex orchestrations involving any/all of the above
|
||||
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
import os.path
|
||||
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from selenium.webdriver import ActionChains
|
||||
from six.moves import range
|
||||
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
from common.test.acceptance.tests.helpers import disable_animations
|
||||
|
||||
|
||||
class CertificatesPage(CoursePage):
|
||||
"""
|
||||
Course Certificates page object wrapper
|
||||
Further below you will also find page objects for Certificates and Signatories
|
||||
"""
|
||||
|
||||
url_path = "certificates"
|
||||
certficate_css = ".certificates-list"
|
||||
|
||||
################
|
||||
# Helpers
|
||||
################
|
||||
|
||||
def refresh(self):
|
||||
"""
|
||||
Refresh the certificate page
|
||||
"""
|
||||
self.browser.refresh()
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Verify that the browser is on the page and it is not still loading.
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.q(css='body.view-certificates').present,
|
||||
'On the certificates page'
|
||||
).fulfill()
|
||||
|
||||
EmptyPromise(
|
||||
lambda: not self.q(css='span.spin').visible,
|
||||
'Certificates are finished loading'
|
||||
).fulfill()
|
||||
|
||||
return True
|
||||
|
||||
def get_first_signatory_title(self):
|
||||
"""
|
||||
Return signatory title for the first signatory in certificate.
|
||||
"""
|
||||
return self.q(css='.signatory-title-value').first.html[0]
|
||||
|
||||
def get_course_number(self):
|
||||
"""
|
||||
Return Course Number
|
||||
"""
|
||||
return self.q(css='.actual-course-number .certificate-value').first.text[0]
|
||||
|
||||
def get_course_number_override(self):
|
||||
"""
|
||||
Return Course Number Override
|
||||
"""
|
||||
return self.q(css='.course-number-override .certificate-value').first.text[0]
|
||||
|
||||
def course_number_override(self):
|
||||
"""
|
||||
Return Course Number Override selector
|
||||
"""
|
||||
return self.q(css='.course-number-override')
|
||||
|
||||
################
|
||||
# Properties
|
||||
################
|
||||
|
||||
@property
|
||||
def certificates(self):
|
||||
"""
|
||||
Return list of the certificates for the course.
|
||||
"""
|
||||
css = self.certficate_css + ' .wrapper-collection'
|
||||
return [CertificateSectionPage(self, self.certficate_css, index) for index in range(len(self.q(css=css)))]
|
||||
|
||||
@property
|
||||
def no_certificates_message_shown(self):
|
||||
"""
|
||||
Returns whether or not no certificates created message is present.
|
||||
"""
|
||||
return self.q(css='.wrapper-content ' + self.certficate_css + ' .no-content').present
|
||||
|
||||
@property
|
||||
def no_certificates_message_text(self):
|
||||
"""
|
||||
Returns text of .no-content container.
|
||||
"""
|
||||
return self.q(css='.wrapper-content ' + self.certficate_css + ' .no-content').text[0]
|
||||
|
||||
@property
|
||||
def new_certificate_link_text(self):
|
||||
"""
|
||||
Returns text of new-button link .
|
||||
"""
|
||||
return self.q(css='.wrapper-content ' + self.certficate_css + ' .no-content a.new-button').text[0]
|
||||
|
||||
################
|
||||
# Wait Actions
|
||||
################
|
||||
|
||||
def wait_for_confirmation_prompt(self):
|
||||
"""
|
||||
Show confirmation prompt
|
||||
We can't use confirm_prompt because its wait_for_notification is flaky when asynchronous operation
|
||||
completed very quickly.
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.q(css='.prompt').present,
|
||||
'Confirmation prompt is displayed'
|
||||
).fulfill()
|
||||
EmptyPromise(
|
||||
lambda: self.q(css='.prompt .action-primary').present,
|
||||
'Primary button is displayed'
|
||||
).fulfill()
|
||||
EmptyPromise(
|
||||
lambda: self.q(css='.prompt .action-primary').visible,
|
||||
'Primary button is visible'
|
||||
).fulfill()
|
||||
|
||||
def wait_for_first_certificate_button(self):
|
||||
"""
|
||||
Ensure the button is available for use
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.q(css=self.certficate_css + " .new-button").present,
|
||||
'Create first certificate button is displayed'
|
||||
).fulfill()
|
||||
|
||||
def wait_for_add_certificate_button(self):
|
||||
"""
|
||||
Ensure the button is available for use
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.q(css=self.certficate_css + " .action-add").present,
|
||||
'Add certificate button is displayed'
|
||||
).fulfill()
|
||||
|
||||
################
|
||||
# Click Actions
|
||||
################
|
||||
|
||||
def click_first_certificate_button(self):
|
||||
"""
|
||||
Clicks the 'Create your first certificate' button, which is only displayed at zero state
|
||||
"""
|
||||
self.wait_for_first_certificate_button()
|
||||
self.q(css=self.certficate_css + " .new-button").first.click()
|
||||
|
||||
def click_add_certificate_button(self):
|
||||
"""
|
||||
Clicks the 'Add new certificate' button, which is displayed when certificates already exist
|
||||
"""
|
||||
self.wait_for_add_certificate_button()
|
||||
self.q(css=self.certficate_css + " .action-add").first.click()
|
||||
|
||||
def click_confirmation_prompt_primary_button(self):
|
||||
"""
|
||||
Clicks the main action presented by the prompt (such as 'Delete')
|
||||
"""
|
||||
disable_animations(self)
|
||||
self.wait_for_confirmation_prompt()
|
||||
self.q(css='.prompt button.action-primary').first.click()
|
||||
self.wait_for_element_invisibility('.prompt', 'wait for pop up to disappear')
|
||||
self.wait_for_ajax()
|
||||
|
||||
|
||||
class CertificateSectionPage(CertificatesPage):
|
||||
"""
|
||||
CertificateSectionPage is the certificate section within Certificates page, There might be multiple certificates
|
||||
in a Certificates Page so this section object can be used to used to identify unique certificate and apply
|
||||
operations on it.
|
||||
"""
|
||||
|
||||
def __init__(self, container, prefix, index):
|
||||
"""
|
||||
Initialize CertificateSection Page
|
||||
|
||||
:param container: Container Page Object of the certificate section
|
||||
:param prefix: css selector of the container element
|
||||
:param index: index of section in the certificate list on the page
|
||||
|
||||
:return:
|
||||
"""
|
||||
self.selector = prefix + u' .certificates-list-item-{}'.format(index)
|
||||
self.index = index
|
||||
|
||||
super(CertificateSectionPage, self).__init__(container.browser, **container.course_info)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Verify that the browser is on the page and it is not still loading.
|
||||
"""
|
||||
return self.q(css=".certificates").present
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
Construct a URL to the page section within the certificate page.
|
||||
"""
|
||||
# This is a page section and can not be accessed directly
|
||||
return None
|
||||
|
||||
################
|
||||
# Helpers
|
||||
################
|
||||
|
||||
def get_selector(self, css=''):
|
||||
"""
|
||||
Return selector fo certificate container
|
||||
"""
|
||||
return ' '.join([self.selector, css])
|
||||
|
||||
def find_css(self, css_selector):
|
||||
"""
|
||||
Find elements as defined by css locator.
|
||||
"""
|
||||
return self.q(css=self.get_selector(css=css_selector))
|
||||
|
||||
def get_text(self, css):
|
||||
"""
|
||||
Return text for the defined by css locator.
|
||||
"""
|
||||
return self.find_css(css).first.text[0]
|
||||
|
||||
################
|
||||
# Properties
|
||||
################
|
||||
|
||||
@property
|
||||
def validation_message(self):
|
||||
"""
|
||||
Return validation message.
|
||||
"""
|
||||
return self.get_text('.message-status.error')
|
||||
|
||||
@property
|
||||
def mode(self):
|
||||
"""
|
||||
Return certificate mode.
|
||||
"""
|
||||
if self.find_css('.collection-edit').present:
|
||||
return 'edit'
|
||||
elif self.find_css('.collection').present:
|
||||
return 'details'
|
||||
|
||||
@property
|
||||
# pylint: disable=invalid-name
|
||||
def id(self):
|
||||
"""
|
||||
Returns certificate id.
|
||||
"""
|
||||
return self.get_text('.certificate-id .certificate-value')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
Return certificate name.
|
||||
"""
|
||||
return self.get_text('.name')
|
||||
|
||||
@name.setter
|
||||
def name(self, value):
|
||||
"""
|
||||
Set certificate name.
|
||||
"""
|
||||
self.find_css('.collection-name-input').first.fill(value)
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""
|
||||
Return certificate description.
|
||||
"""
|
||||
return self.get_text('.certificate-description')
|
||||
|
||||
@description.setter
|
||||
def description(self, value):
|
||||
"""
|
||||
Set certificate description.
|
||||
"""
|
||||
self.find_css('.certificate-description-input').first.fill(value)
|
||||
|
||||
@property
|
||||
def course_title(self):
|
||||
"""
|
||||
Return certificate course title override field.
|
||||
"""
|
||||
return self.get_text('.course-title-override .certificate-value')
|
||||
|
||||
@course_title.setter
|
||||
def course_title(self, value):
|
||||
"""
|
||||
Set certificate course title override field.
|
||||
"""
|
||||
self.find_css('.certificate-course-title-input').first.fill(value)
|
||||
|
||||
@property
|
||||
def signatories(self):
|
||||
"""
|
||||
Return list of the signatories for the certificate.
|
||||
"""
|
||||
css = self.selector + ' .signatory-' + self.mode
|
||||
return [SignatorySectionPage(self, self.selector, self.mode, index) for index in range(len(self.q(css=css)))]
|
||||
|
||||
################
|
||||
# Wait Actions
|
||||
################
|
||||
|
||||
def wait_for_certificate_delete_button(self):
|
||||
"""
|
||||
Returns whether or not the certificate delete icon is present.
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.find_css('.actions .delete.action-icon').present,
|
||||
'Certificate delete button is displayed'
|
||||
).fulfill()
|
||||
|
||||
def wait_for_hide_details_toggle(self):
|
||||
"""
|
||||
Certificate details are expanded.
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.find_css('a.detail-toggle.hide-details').present,
|
||||
'Certificate details are expanded'
|
||||
).fulfill()
|
||||
|
||||
################
|
||||
# Click Actions
|
||||
################
|
||||
|
||||
def click_create_certificate_button(self):
|
||||
"""
|
||||
Create a new certificate.
|
||||
"""
|
||||
disable_animations(self)
|
||||
self.find_css('.action-primary').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def click_save_certificate_button(self):
|
||||
"""
|
||||
Save certificate.
|
||||
"""
|
||||
self.find_css('.action-primary').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def click_add_signatory_button(self):
|
||||
"""
|
||||
Add signatory to certificate
|
||||
"""
|
||||
self.find_css('.action-add-signatory').first.click()
|
||||
|
||||
def click_edit_certificate_button(self):
|
||||
"""
|
||||
Open editing view for the certificate.
|
||||
"""
|
||||
self.find_css('.action-edit .edit').first.click()
|
||||
|
||||
def click_cancel_edit_certificate(self):
|
||||
"""
|
||||
Cancel certificate editing.
|
||||
"""
|
||||
self.find_css('.action-secondary').first.click()
|
||||
|
||||
def click_certificate_details_toggle(self):
|
||||
"""
|
||||
Expand/collapse certificate configuration.
|
||||
"""
|
||||
self.find_css('a.detail-toggle').first.click()
|
||||
|
||||
def click_delete_certificate_button(self):
|
||||
"""
|
||||
Remove the first (possibly the only) certificate from the set
|
||||
"""
|
||||
self.wait_for_certificate_delete_button()
|
||||
self.find_css('.actions .delete.action-icon').first.click()
|
||||
|
||||
|
||||
class SignatorySectionPage(CertificatesPage):
|
||||
"""
|
||||
SignatorySectionPage is the signatory section within CertificatesSection, There might be multiple signatories
|
||||
in a certificate section so this section object can be used to used to identify unique section and apply
|
||||
operations on it.
|
||||
"""
|
||||
def __init__(self, container, prefix, mode, index):
|
||||
"""
|
||||
Initialize SignatorySection Page
|
||||
|
||||
:param container: Container Section Page Object of the Signatory section
|
||||
:param prefix: css selector of the container element
|
||||
:param index: index of section in the signatory list on the page
|
||||
:param mode: 'details' or 'edit', showing whether signatory is being displayed or edited
|
||||
|
||||
:return:
|
||||
"""
|
||||
self.prefix = prefix
|
||||
self.index = index
|
||||
self.mode = mode
|
||||
|
||||
super(SignatorySectionPage, self).__init__(container.browser, **container.course_info)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Verify that the browser is on the page and it is not still loading.
|
||||
"""
|
||||
return self.q(css=self.prefix + " .signatory-details-list, .signatory-edit-list").present
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
Construct a URL to the page section within the certificate section page.
|
||||
"""
|
||||
# This is a page section and can not be accessed directly
|
||||
return None
|
||||
|
||||
################
|
||||
# Helpers
|
||||
################
|
||||
|
||||
@staticmethod
|
||||
def file_path(filename):
|
||||
"""
|
||||
Construct file path to be uploaded from the data upload folder.
|
||||
|
||||
Arguments:
|
||||
filename (str): asset filename
|
||||
|
||||
"""
|
||||
# Should grab common point between this page module and the data folder.
|
||||
return os.sep.join(os.path.abspath(__file__).split(os.sep)[:-4]) + '/data/uploads/' + filename
|
||||
|
||||
def get_selector(self, css=''):
|
||||
"""
|
||||
Return selector fo signatory container
|
||||
"""
|
||||
selector = self.prefix + u' .signatory-{}-view-{}'.format(self.mode, self.index)
|
||||
return ' '.join([selector, css])
|
||||
|
||||
def find_css(self, css_selector):
|
||||
"""
|
||||
Find elements as defined by css locator.
|
||||
"""
|
||||
return self.q(css=self.get_selector(css=css_selector))
|
||||
|
||||
################
|
||||
# Properties
|
||||
################
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
Return signatory name.
|
||||
"""
|
||||
return self.find_css('.signatory-panel-body .signatory-name-value').first.text[0]
|
||||
|
||||
@name.setter
|
||||
def name(self, value):
|
||||
"""
|
||||
Set signatory name.
|
||||
"""
|
||||
self.find_css('.signatory-name-input').first.fill(value)
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
"""
|
||||
Return signatory title.
|
||||
"""
|
||||
return self.find_css('.signatory-panel-body .signatory-title-value').first.text[0]
|
||||
|
||||
@title.setter
|
||||
def title(self, value):
|
||||
"""
|
||||
Set signatory title.
|
||||
"""
|
||||
self.find_css('.signatory-title-input').first.fill(value)
|
||||
|
||||
@property
|
||||
def organization(self):
|
||||
"""
|
||||
Return signatory organization.
|
||||
"""
|
||||
return self.find_css('.signatory-panel-body .signatory-organization-value').first.text[0]
|
||||
|
||||
@organization.setter
|
||||
def organization(self, value):
|
||||
"""
|
||||
Set signatory organization.
|
||||
"""
|
||||
self.find_css('.signatory-organization-input').first.fill(value)
|
||||
|
||||
################
|
||||
# Workflows
|
||||
################
|
||||
|
||||
def edit(self):
|
||||
"""
|
||||
Open editing view for the signatory.
|
||||
"""
|
||||
element = self.q(css='.edit-signatory').results[0]
|
||||
mouse_hover_action = ActionChains(self.browser).move_to_element(element)
|
||||
mouse_hover_action.perform()
|
||||
self.wait_for_element_visibility('.edit-signatory', 'Edit button visibility')
|
||||
element.click()
|
||||
self.wait_for_signatory_edit_view()
|
||||
|
||||
def delete_signatory(self):
|
||||
"""
|
||||
Delete the signatory
|
||||
"""
|
||||
self.wait_for_signatory_delete_icon()
|
||||
self.click_signatory_delete_icon()
|
||||
self.wait_for_signatory_delete_prompt()
|
||||
|
||||
self.q(css='#prompt-warning a.button.action-primary').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Save signatory.
|
||||
"""
|
||||
# Click on the save button.
|
||||
self.q(css='button.signatory-panel-save').click()
|
||||
self.mode = 'details'
|
||||
self.wait_for_ajax()
|
||||
self.wait_for_signatory_detail_view()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Cancel signatory editing.
|
||||
"""
|
||||
self.q(css='button.signatory-panel-close').click()
|
||||
self.mode = 'details'
|
||||
self.wait_for_signatory_detail_view()
|
||||
|
||||
def upload_signature_image(self, image_filename):
|
||||
"""
|
||||
Opens upload image dialog and upload given image file.
|
||||
"""
|
||||
self.wait_for_signature_image_upload_button()
|
||||
self.find_css('.action-upload-signature').first.click()
|
||||
self.wait_for_signature_image_upload_prompt()
|
||||
|
||||
asset_file_path = self.file_path(image_filename)
|
||||
self.q(
|
||||
css='.assetupload-modal .upload-dialog input[type="file"]'
|
||||
)[0].send_keys(asset_file_path)
|
||||
|
||||
EmptyPromise(
|
||||
lambda: not self.q(
|
||||
css='.assetupload-modal a.action-upload.disabled'
|
||||
).present,
|
||||
'Upload button is not disabled anymore'
|
||||
).fulfill()
|
||||
|
||||
self.q(css='.assetupload-modal a.action-upload').first.click()
|
||||
EmptyPromise(
|
||||
lambda: not self.q(css='.assetupload-modal .upload-dialog').visible,
|
||||
'Upload dialog is removed after uploading image'
|
||||
).fulfill()
|
||||
|
||||
################
|
||||
# Wait Actions
|
||||
################
|
||||
|
||||
@property
|
||||
def wait_for_signatory_delete_icon(self):
|
||||
"""
|
||||
Returns whether or not the delete icon is present.
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.q(css='.signatory-panel-delete').present,
|
||||
'Delete icon is displayed'
|
||||
).fulfill()
|
||||
|
||||
def wait_for_signatory_delete_prompt(self):
|
||||
"""
|
||||
Promise to wait until signatory delete prompt is visible
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.q(css='a.button.action-primary').present,
|
||||
'Delete prompt is displayed'
|
||||
).fulfill()
|
||||
|
||||
def wait_for_signatory_edit_view(self):
|
||||
"""
|
||||
Promise to wait until signatory edit view is loaded
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.find_css('.signatory-panel-body .signatory-name-input').present,
|
||||
'On signatory edit view'
|
||||
).fulfill()
|
||||
|
||||
def wait_for_signatory_detail_view(self):
|
||||
"""
|
||||
Promise to wait until signatory details view is loaded
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.find_css('.signatory-panel-body .signatory-name-value').present,
|
||||
'On signatory details view'
|
||||
).fulfill()
|
||||
|
||||
def wait_for_signature_image_upload_prompt(self):
|
||||
"""
|
||||
Promise to wait until signatory image upload prompt is visible
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.q(css='.assetupload-modal .action-upload').present,
|
||||
'Signature image upload dialog opened'
|
||||
).fulfill()
|
||||
|
||||
def wait_for_signature_image_upload_button(self):
|
||||
"""
|
||||
Promise to wait until signatory image upload button is visible
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.q(css=".action-upload-signature").first.present,
|
||||
'Signature image upload button available'
|
||||
).fulfill()
|
||||
|
||||
@property
|
||||
def wait_for_signature_image(self):
|
||||
"""
|
||||
Promise for the signature image to be displayed
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.q(css=".current-signature-image .signature-image").present,
|
||||
'Signature image available'
|
||||
).fulfill()
|
||||
|
||||
################
|
||||
# Click Actions
|
||||
################
|
||||
|
||||
def click_signatory_delete_icon(self):
|
||||
"""
|
||||
Clicks the signatory deletion icon/action
|
||||
"""
|
||||
self.find_css('.signatory-panel-delete').first.click()
|
||||
@@ -1,365 +0,0 @@
|
||||
"""
|
||||
Course Grading Settings page.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.javascript import requirejs
|
||||
from bok_choy.promise import BrokenPromise
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from six.moves import range
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css
|
||||
from common.test.acceptance.pages.studio.settings import SettingsPage
|
||||
from common.test.acceptance.pages.studio.utils import press_the_notification_button
|
||||
|
||||
|
||||
@requirejs('js/factories/settings_graders')
|
||||
class GradingPage(SettingsPage):
|
||||
"""
|
||||
Course Grading Settings page.
|
||||
"""
|
||||
|
||||
url_path = "settings/grading"
|
||||
grade_ranges = '.grades .grade-specific-bar'
|
||||
grace_period_field = '#course-grading-graceperiod'
|
||||
assignments = '.field-group.course-grading-assignment-list-item'
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='body.grading').present
|
||||
|
||||
def letter_grade(self, selector):
|
||||
"""
|
||||
Returns: first letter of grade range on grading page
|
||||
Example: if there are no manually added grades it would
|
||||
return Pass, if a grade is added it will return 'A'
|
||||
"""
|
||||
return self.q(css=selector)[0].text
|
||||
|
||||
def add_new_assignment_type(self):
|
||||
"""
|
||||
Click New Assignment Type button.
|
||||
"""
|
||||
self.q(css='.new-button.new-course-grading-item.add-grading-data').click()
|
||||
|
||||
@property
|
||||
def total_number_of_grades(self):
|
||||
"""
|
||||
Gets total number of grades present in the grades bar
|
||||
Returns:
|
||||
int: Single number length of grades
|
||||
"""
|
||||
self.wait_for_element_visibility(self.grade_ranges, 'Grades are visible')
|
||||
return len(self.q(css=self.grade_ranges))
|
||||
|
||||
def add_new_grade(self):
|
||||
"""
|
||||
Add new grade
|
||||
"""
|
||||
self.q(css='.new-grade-button').click()
|
||||
self.save_changes()
|
||||
|
||||
def remove_grade(self):
|
||||
"""
|
||||
Remove an added grade
|
||||
"""
|
||||
# Button displays after hovering on it
|
||||
btn_css = '.remove-button'
|
||||
self.browser.execute_script("$('{}').focus().click()".format(btn_css))
|
||||
self.wait_for_ajax()
|
||||
self.save_changes()
|
||||
|
||||
def remove_grades(self, number_of_grades):
|
||||
"""
|
||||
Remove grade ranges from grades bar.
|
||||
"""
|
||||
for _ in range(number_of_grades):
|
||||
self.browser.execute_script('document.getElementsByClassName("remove-button")[0].click()')
|
||||
|
||||
def remove_all_grades(self):
|
||||
"""
|
||||
Removes all grades
|
||||
"""
|
||||
while len(self.q(css='.remove-button')) > 0:
|
||||
self.remove_grade()
|
||||
|
||||
def drag_and_drop_grade(self):
|
||||
"""
|
||||
Drag and drop grade range.
|
||||
"""
|
||||
self.wait_for_element_visibility(self.grade_ranges, "Grades ranges are visible")
|
||||
action = ActionChains(self.browser)
|
||||
moveable_css = self.q(css='.ui-resizable-e').results[0]
|
||||
action.drag_and_drop_by_offset(moveable_css, -280, 0)
|
||||
action.perform()
|
||||
|
||||
@property
|
||||
def get_assignment_names(self):
|
||||
"""
|
||||
Get name of the all the assignment types.
|
||||
Returns:
|
||||
list: A list containing names of the assignment types.
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'#course-grading-assignment-name',
|
||||
'Grade Names not visible.'
|
||||
)
|
||||
return self.q(css='#course-grading-assignment-name').attrs('value')
|
||||
|
||||
def change_assignment_name(self, old_name, new_name):
|
||||
"""
|
||||
Changes the assignment name.
|
||||
Arguments:
|
||||
old_name (str): The assignment type name which is to be changed.
|
||||
new_name (str): New name of the assignment.
|
||||
"""
|
||||
self.wait_for_element_visibility('#course-grading-assignment-name', 'Assignment Name field visible')
|
||||
self.q(css='#course-grading-assignment-name').filter(
|
||||
lambda el: el.get_attribute('value') == old_name).fill(new_name)
|
||||
|
||||
def set_weight(self, assignment_name, weight):
|
||||
"""
|
||||
Set the weight of the assignment type.
|
||||
|
||||
Arguments:
|
||||
assignment_name (string): Assignment name for which weight is to be changed.
|
||||
weight (string): New weight
|
||||
"""
|
||||
weight_id = '#course-grading-assignment-gradeweight'
|
||||
f = self.q(css=weight_id).results[-1]
|
||||
for __ in range(len(assignment_name)):
|
||||
f.send_keys(Keys.END, Keys.BACK_SPACE)
|
||||
f.send_keys(weight)
|
||||
|
||||
def get_assignment_weight(self, assignment_name):
|
||||
"""
|
||||
Gets the weight of assignment
|
||||
|
||||
Arguments:
|
||||
assignment_name (str): Name of the assignment
|
||||
Returns:
|
||||
string: Weight of the assignment
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'#course-grading-assignment-gradeweight',
|
||||
'Weight fields are present'
|
||||
)
|
||||
weight_id = '#course-grading-assignment-gradeweight'
|
||||
index = self._get_type_index(assignment_name)
|
||||
all_weight_elements = self.q(css=weight_id).results
|
||||
return all_weight_elements[index].get_attribute('value')
|
||||
|
||||
def is_notification_button_disbaled(self):
|
||||
"""
|
||||
Check to see if notification button is disabled.
|
||||
|
||||
Returns:
|
||||
bool: True if button is disabled.
|
||||
"""
|
||||
self.wait_for_element_visibility('.nav-actions>ul', 'Notification bar not visible.')
|
||||
return self.q(css='.action-primary.action-save.is-disabled').present
|
||||
|
||||
def edit_grade_name(self, new_grade_name):
|
||||
"""
|
||||
Edit name of the highest grade.
|
||||
"""
|
||||
self.wait_for_element_visibility(self.grade_ranges, 'Grades are visible')
|
||||
self.q(css='span[contenteditable="true"]').fill(new_grade_name)
|
||||
|
||||
def try_edit_fail_grade(self, field_value):
|
||||
"""
|
||||
Try to edit the name of lowest grade.
|
||||
"""
|
||||
self.wait_for_element_visibility(self.grade_ranges, 'Grades are visible')
|
||||
try:
|
||||
self.q(css='span[contenteditable="false"]').fill(field_value)
|
||||
except BrokenPromise:
|
||||
pass
|
||||
|
||||
@property
|
||||
def highest_grade_name(self):
|
||||
"""
|
||||
Get name of the highest grade.
|
||||
"""
|
||||
self.wait_for_element_visibility(self.grade_ranges, 'Grades are visible')
|
||||
return self.q(css='span[contenteditable="true"]').first.text[0]
|
||||
|
||||
@property
|
||||
def lowest_grade_name(self):
|
||||
"""
|
||||
Get name of the lowest grade.
|
||||
"""
|
||||
self.wait_for_element_visibility(self.grade_ranges, 'Grades are visible')
|
||||
return self.q(css='span[contenteditable="false"]').first.text[0]
|
||||
|
||||
@property
|
||||
def grace_period_value(self):
|
||||
"""
|
||||
Get the grace period field value.
|
||||
"""
|
||||
self.wait_for(
|
||||
lambda: self.q(css='#course-grading-graceperiod').attrs('value')[0] != '00:00',
|
||||
description="Grace period field is updated after save"
|
||||
)
|
||||
return self.q(css='#course-grading-graceperiod').attrs('value')[0]
|
||||
|
||||
@property
|
||||
def grade_letters(self):
|
||||
"""
|
||||
Get names of grade ranges.
|
||||
|
||||
Returns:
|
||||
list: A list containing names of the grade ranges.
|
||||
"""
|
||||
return self.q(css='.letter-grade').text
|
||||
|
||||
def click_add_grade(self):
|
||||
"""
|
||||
Clicks to add a grade
|
||||
"""
|
||||
click_css(self, '.new-grade-button', require_notification=False)
|
||||
|
||||
def is_grade_added(self, length):
|
||||
"""
|
||||
Checks to see if grade is added by comparing number of grades after the addition
|
||||
|
||||
Returns:
|
||||
bool: True if grade is added
|
||||
bool: False if grade is not added
|
||||
"""
|
||||
try:
|
||||
self.wait_for(
|
||||
lambda: len(self.q(css=self.grade_ranges)) == length + 1,
|
||||
description="Grades are added",
|
||||
timeout=3
|
||||
)
|
||||
return True
|
||||
except BrokenPromise:
|
||||
return False
|
||||
|
||||
@property
|
||||
def grades_range(self):
|
||||
"""
|
||||
Get ranges of all the grades.
|
||||
|
||||
Returns:
|
||||
list: A list containing ranges of all the grades
|
||||
"""
|
||||
self.wait_for_element_visibility('.range', 'Ranges are visible')
|
||||
return self.q(css='.range').text
|
||||
|
||||
def fill_assignment_type_fields(
|
||||
self,
|
||||
name,
|
||||
abbreviation,
|
||||
total_grade,
|
||||
total_number,
|
||||
drop
|
||||
):
|
||||
"""
|
||||
Fills text to Assignment Type fields according to assignment box
|
||||
number and text provided
|
||||
|
||||
Arguments:
|
||||
name: Assignment Type Name
|
||||
abbreviation: Abbreviation
|
||||
total_grade: Weight of Total Grade
|
||||
total_number: Total Number
|
||||
drop: Number of Droppable
|
||||
"""
|
||||
self.q(css='#course-grading-assignment-name').fill(name)
|
||||
self.q(css='#course-grading-assignment-shortname').fill(abbreviation)
|
||||
self.q(css='#course-grading-assignment-gradeweight').fill(total_grade)
|
||||
self.q(
|
||||
css='#course-grading-assignment-totalassignments'
|
||||
).fill(total_number)
|
||||
|
||||
self.q(css='#course-grading-assignment-droppable').fill(drop)
|
||||
self.save_changes()
|
||||
|
||||
def assignment_name_field_value(self):
|
||||
"""
|
||||
Returns:
|
||||
list: Assignment type field value
|
||||
"""
|
||||
return self.q(css='#course-grading-assignment-name').attrs('value')
|
||||
|
||||
def delete_assignment_type(self):
|
||||
"""
|
||||
Deletes Assignment type
|
||||
"""
|
||||
self.q(css='.remove-grading-data').first.click()
|
||||
self.save_changes()
|
||||
|
||||
def delete_all_assignment_types(self):
|
||||
"""
|
||||
Deletes all assignment types
|
||||
"""
|
||||
while len(self.q(css='.remove-grading-data')) > 0:
|
||||
self.delete_assignment_type()
|
||||
|
||||
@property
|
||||
def confirmation_message(self):
|
||||
"""
|
||||
Get confirmation message received after saving settings.
|
||||
"""
|
||||
self.wait_for_element_visibility('#alert-confirmation-title', 'Confirmation text present')
|
||||
return self.q(css='#alert-confirmation-title').text[0]
|
||||
|
||||
def _get_type_index(self, name):
|
||||
"""
|
||||
Gets the index of assignment type.
|
||||
|
||||
Arguments:
|
||||
name(str): name of the assignment
|
||||
|
||||
Returns:
|
||||
int: index of the assignment type
|
||||
"""
|
||||
name_id = '#course-grading-assignment-name'
|
||||
all_types = self.q(css=name_id).results
|
||||
for index, element in enumerate(all_types):
|
||||
if element.get_attribute('value') == name:
|
||||
return index
|
||||
return -1
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Click on save settings button.
|
||||
"""
|
||||
press_the_notification_button(self, "Save")
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Click on cancel settings button.
|
||||
"""
|
||||
press_the_notification_button(self, "Cancel")
|
||||
|
||||
def set_grace_period(self, grace_time_value):
|
||||
"""
|
||||
Set value in grace period field.
|
||||
"""
|
||||
self.set_element_value(grace_time_value)
|
||||
|
||||
def check_field_value(self, field_value):
|
||||
"""
|
||||
Check updated values in input field
|
||||
"""
|
||||
self.wait_for(
|
||||
lambda: self.q(css='#course-grading-graceperiod').attrs('value')[0] == field_value,
|
||||
"Value of input field is correct."
|
||||
)
|
||||
|
||||
def set_element_value(self, element_value):
|
||||
"""
|
||||
Set the values of the elements to those specified
|
||||
in the element_values dict.
|
||||
"""
|
||||
element = self.q(css='#course-grading-graceperiod').results[0]
|
||||
element.click()
|
||||
element.clear()
|
||||
self.wait_for(
|
||||
lambda: self.q(css='#course-grading-graceperiod').attrs('value')[0] == '',
|
||||
"Value of input field is correct."
|
||||
)
|
||||
element.send_keys(element_value)
|
||||
@@ -1,358 +0,0 @@
|
||||
"""
|
||||
Course Group Configurations page.
|
||||
"""
|
||||
|
||||
|
||||
from six.moves import range
|
||||
|
||||
from common.test.acceptance.pages.common.utils import confirm_prompt
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
|
||||
|
||||
class GroupConfigurationsPage(CoursePage):
|
||||
"""
|
||||
Course Group Configurations page.
|
||||
"""
|
||||
|
||||
url_path = "group_configurations"
|
||||
experiment_groups_css = ".experiment-groups"
|
||||
content_groups_css = ".content-groups"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Verify that the browser is on the page and it is not still loading.
|
||||
"""
|
||||
return all([
|
||||
self.q(css='body.view-group-configurations').present,
|
||||
self.q(css='div.ui-loading.is-hidden').present
|
||||
])
|
||||
|
||||
@property
|
||||
def experiment_group_configurations(self):
|
||||
"""
|
||||
Return list of the experiment group configurations for the course.
|
||||
"""
|
||||
return self._get_groups(self.experiment_groups_css)
|
||||
|
||||
@property
|
||||
def content_groups(self):
|
||||
"""
|
||||
Return list of the content groups for the course.
|
||||
"""
|
||||
return self._get_groups(self.content_groups_css)
|
||||
|
||||
def _get_groups(self, prefix):
|
||||
"""
|
||||
Return list of the group-configurations-list-item's of specified type for the course.
|
||||
"""
|
||||
css = prefix + ' .wrapper-collection'
|
||||
return [GroupConfiguration(self, prefix, index) for index in range(len(self.q(css=css)))]
|
||||
|
||||
def create_experiment_group_configuration(self):
|
||||
"""
|
||||
Creates new group configuration.
|
||||
"""
|
||||
self.q(css=self.experiment_groups_css + " .new-button").first.click()
|
||||
|
||||
def create_first_content_group(self):
|
||||
"""
|
||||
Creates new content group when there are none initially defined.
|
||||
"""
|
||||
self.q(css=self.content_groups_css + " .new-button").first.click()
|
||||
|
||||
def add_content_group(self):
|
||||
"""
|
||||
Creates new content group when at least one already exists
|
||||
"""
|
||||
self.q(css=self.content_groups_css + " .action-add").first.click()
|
||||
|
||||
@property
|
||||
def no_experiment_groups_message_is_present(self):
|
||||
return self._no_content_message(self.experiment_groups_css).present
|
||||
|
||||
@property
|
||||
def no_content_groups_message_is_present(self):
|
||||
return self._no_content_message(self.content_groups_css).present
|
||||
|
||||
@property
|
||||
def no_experiment_groups_message_text(self):
|
||||
return self._no_content_message(self.experiment_groups_css).text[0]
|
||||
|
||||
@property
|
||||
def no_content_groups_message_text(self):
|
||||
return self._no_content_message(self.content_groups_css).text[0]
|
||||
|
||||
def _no_content_message(self, prefix):
|
||||
"""
|
||||
Returns the message about "no content" for the specified type.
|
||||
"""
|
||||
return self.q(css='.wrapper-content ' + prefix + ' .no-content')
|
||||
|
||||
@property
|
||||
def experiment_group_sections_present(self):
|
||||
"""
|
||||
Returns whether or not anything related to content experiments is present.
|
||||
"""
|
||||
return self.q(css=self.experiment_groups_css).present or self.q(css=".experiment-groups-doc").present
|
||||
|
||||
@property
|
||||
def enrollment_track_section_present(self):
|
||||
return self.q(css='.wrapper-groups.content-groups.enrollment_track').present
|
||||
|
||||
@property
|
||||
def enrollment_track_edit_present(self):
|
||||
return self.q(css='.wrapper-groups.content-groups.enrollment_track .action.action-edit').present
|
||||
|
||||
def get_enrollment_groups(self):
|
||||
return self.q(css='.wrapper-groups.content-groups.enrollment_track .collection-details .title').text
|
||||
|
||||
|
||||
class GroupConfiguration(object):
|
||||
"""
|
||||
Group Configuration wrapper.
|
||||
"""
|
||||
|
||||
def __init__(self, page, prefix, index):
|
||||
self.page = page
|
||||
self.SELECTOR = prefix + u' .wrapper-collection-{}'.format(index)
|
||||
self.index = index
|
||||
|
||||
def get_selector(self, css=''):
|
||||
return ' '.join([self.SELECTOR, css])
|
||||
|
||||
def find_css(self, selector):
|
||||
"""
|
||||
Find elements as defined by css locator.
|
||||
"""
|
||||
return self.page.q(css=self.get_selector(css=selector))
|
||||
|
||||
def toggle(self):
|
||||
"""
|
||||
Expand/collapse group configuration.
|
||||
"""
|
||||
self.find_css('a.group-toggle').first.click()
|
||||
|
||||
@property
|
||||
def is_expanded(self):
|
||||
"""
|
||||
Group configuration usage information is expanded.
|
||||
"""
|
||||
return self.find_css('a.group-toggle.hide-groups').present
|
||||
|
||||
def add_group(self):
|
||||
"""
|
||||
Add new group.
|
||||
"""
|
||||
self.find_css('button.action-add-group').first.click()
|
||||
|
||||
def get_text(self, css):
|
||||
"""
|
||||
Return text for the defined by css locator.
|
||||
"""
|
||||
return self.find_css(css).first.text[0]
|
||||
|
||||
def click_outline_anchor(self):
|
||||
"""
|
||||
Click on the `Course Outline` link.
|
||||
"""
|
||||
self.find_css('p.group-configuration-usage-text a').first.click()
|
||||
|
||||
def click_unit_anchor(self, index=0):
|
||||
"""
|
||||
Click on the link to the unit.
|
||||
"""
|
||||
self.find_css('li.group-configuration-usage-unit a').nth(index).click()
|
||||
|
||||
def edit(self):
|
||||
"""
|
||||
Open editing view for the group configuration.
|
||||
"""
|
||||
self.find_css('.action-edit .edit').first.click()
|
||||
|
||||
@property
|
||||
def delete_button_is_disabled(self):
|
||||
return self.find_css('.actions .delete.is-disabled').present
|
||||
|
||||
@property
|
||||
def delete_button_is_present(self):
|
||||
"""
|
||||
Returns whether or not the delete icon is present.
|
||||
"""
|
||||
return self.find_css('.actions .delete').present
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Delete the group configuration.
|
||||
"""
|
||||
self.find_css('.actions .delete').first.click()
|
||||
confirm_prompt(self.page)
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Save group configuration.
|
||||
"""
|
||||
self.find_css('.action-primary').first.click()
|
||||
self.page.wait_for_ajax()
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Cancel group configuration.
|
||||
"""
|
||||
self.find_css('.action-secondary').first.click()
|
||||
|
||||
@property
|
||||
def mode(self):
|
||||
"""
|
||||
Return group configuration mode.
|
||||
"""
|
||||
if self.find_css('.collection-edit').present:
|
||||
return 'edit'
|
||||
elif self.find_css('.collection').present:
|
||||
return 'details'
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
"""
|
||||
Return group configuration id.
|
||||
"""
|
||||
return self.get_text('.group-configuration-id .group-configuration-value')
|
||||
|
||||
@property
|
||||
def validation_message(self):
|
||||
"""
|
||||
Return validation message.
|
||||
"""
|
||||
return self.get_text('.message-status.error')
|
||||
|
||||
@property
|
||||
def usages(self):
|
||||
"""
|
||||
Return list of usages.
|
||||
"""
|
||||
css = '.group-configuration-usage-unit'
|
||||
return self.find_css(css).text
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
Return group configuration name.
|
||||
"""
|
||||
return self.get_text('.title')
|
||||
|
||||
@name.setter
|
||||
def name(self, value):
|
||||
"""
|
||||
Set group configuration name.
|
||||
"""
|
||||
return self.find_css('.collection-name-input').first.fill(value)
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""
|
||||
Return group configuration description.
|
||||
"""
|
||||
return self.get_text('.group-configuration-description')
|
||||
|
||||
@description.setter
|
||||
def description(self, value):
|
||||
"""
|
||||
Set group configuration description.
|
||||
"""
|
||||
self.find_css('.group-configuration-description-input').first.fill(value)
|
||||
|
||||
@property
|
||||
def groups(self):
|
||||
"""
|
||||
Return list of groups.
|
||||
"""
|
||||
def group_selector(group_index):
|
||||
return self.get_selector('.group-{} '.format(group_index))
|
||||
|
||||
return [Group(self.page, group_selector(index)) for index, element in enumerate(self.find_css('.group'))]
|
||||
|
||||
@property
|
||||
def delete_note(self):
|
||||
"""
|
||||
Return delete note for the group configuration.
|
||||
"""
|
||||
return self.find_css('.wrapper-delete-button').first.attrs('data-tooltip')[0]
|
||||
|
||||
@property
|
||||
def details_error_icon_is_present(self):
|
||||
return self.find_css('.wrapper-group-configuration-usages .fa-exclamation-circle').present
|
||||
|
||||
@property
|
||||
def details_warning_icon_is_present(self):
|
||||
return self.find_css('.wrapper-group-configuration-usages .fa-warning').present
|
||||
|
||||
@property
|
||||
def details_message_is_present(self):
|
||||
return self.find_css('.wrapper-group-configuration-usages .group-configuration-validation-message').present
|
||||
|
||||
@property
|
||||
def details_message_text(self):
|
||||
return self.find_css('.wrapper-group-configuration-usages .group-configuration-validation-message').text[0]
|
||||
|
||||
@property
|
||||
def edit_warning_icon_is_present(self):
|
||||
return self.find_css('.wrapper-group-configuration-validation .fa-warning').present
|
||||
|
||||
@property
|
||||
def edit_warning_message_is_present(self):
|
||||
return self.find_css('.wrapper-group-configuration-validation .group-configuration-validation-text').present
|
||||
|
||||
@property
|
||||
def edit_warning_message_text(self):
|
||||
return self.find_css('.wrapper-group-configuration-validation .group-configuration-validation-text').text[0]
|
||||
|
||||
def __repr__(self):
|
||||
return "<{}:{}>".format(self.__class__.__name__, self.name)
|
||||
|
||||
|
||||
class Group(object):
|
||||
"""
|
||||
Group wrapper.
|
||||
"""
|
||||
def __init__(self, page, prefix_selector):
|
||||
self.page = page
|
||||
self.prefix = prefix_selector
|
||||
|
||||
def find_css(self, selector):
|
||||
"""
|
||||
Find elements as defined by css locator.
|
||||
"""
|
||||
return self.page.q(css=self.prefix + selector)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
Return the name of the group .
|
||||
"""
|
||||
css = '.group-name'
|
||||
return self.find_css(css).first.text[0]
|
||||
|
||||
@name.setter
|
||||
def name(self, value):
|
||||
"""
|
||||
Set the name for the group.
|
||||
"""
|
||||
css = '.group-name'
|
||||
self.find_css(css).first.fill(value)
|
||||
|
||||
@property
|
||||
def allocation(self):
|
||||
"""
|
||||
Return allocation for the group.
|
||||
"""
|
||||
css = '.group-allocation'
|
||||
return self.find_css(css).first.text[0]
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the group.
|
||||
"""
|
||||
css = '.action-close'
|
||||
return self.find_css(css).first.click()
|
||||
|
||||
def __repr__(self):
|
||||
return "<{}:{}>".format(self.__class__.__name__, self.name)
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
Signup page for studio
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css
|
||||
from common.test.acceptance.pages.studio import LMS_URL
|
||||
from common.test.acceptance.pages.studio.utils import HelpMixin, set_input_value
|
||||
|
||||
|
||||
class SignupPage(PageObject, HelpMixin):
|
||||
"""
|
||||
Signup page for Studio.
|
||||
"""
|
||||
|
||||
url = LMS_URL + "/register"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return (
|
||||
self.q(css="#register-anchor").is_present() and
|
||||
self.q(css=".register-button").visible
|
||||
)
|
||||
|
||||
def input_password(self, password):
|
||||
"""Inputs a password and then returns the password input"""
|
||||
return set_input_value(self, "#register-password", password)
|
||||
|
||||
def sign_up_user(self, email, name, username, password, country="US", favorite_movie="Alf"):
|
||||
"""
|
||||
Register the user.
|
||||
"""
|
||||
self.q(css="#register-email").fill(email)
|
||||
self.q(css="#register-name").fill(name)
|
||||
self.q(css="#register-username").fill(username)
|
||||
self.q(css="#register-password").fill(password)
|
||||
self.q(css="#register-country").results[0].send_keys(country)
|
||||
self.q(css="#register-favorite_movie").fill(favorite_movie)
|
||||
|
||||
# Submit it
|
||||
self.q(css=".register-button").click()
|
||||
self.wait_for_element_absence('.register-button', 'Register button is gone.')
|
||||
@@ -1,150 +0,0 @@
|
||||
"""
|
||||
Course Textbooks page.
|
||||
"""
|
||||
|
||||
|
||||
import requests
|
||||
from path import Path as path
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
|
||||
|
||||
class TextbookUploadPage(CoursePage):
|
||||
"""
|
||||
Course Textbooks page.
|
||||
"""
|
||||
|
||||
url_path = "textbooks"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='.textbooks-list').visible
|
||||
|
||||
def open_add_textbook_form(self):
|
||||
"""
|
||||
Open new textbook form by clicking on new textbook button.
|
||||
"""
|
||||
self.q(css='.nav-item .new-button').click()
|
||||
|
||||
def get_element_text(self, selector):
|
||||
"""
|
||||
Return the text of the css selector.
|
||||
"""
|
||||
return self.q(css=selector)[0].text
|
||||
|
||||
def set_input_field_value(self, selector, value):
|
||||
"""
|
||||
Set the value of input field by selector.
|
||||
"""
|
||||
self.q(css=selector)[0].send_keys(value)
|
||||
|
||||
def upload_pdf_file(self, file_name):
|
||||
"""
|
||||
Uploads a pdf textbook.
|
||||
"""
|
||||
# If the pdf upload section has not yet been toggled on, click on the upload pdf button
|
||||
test_dir = path(__file__).abspath().dirname().dirname().dirname().dirname()
|
||||
file_path = test_dir + '/data/uploads/' + file_name
|
||||
|
||||
click_css(self, ".edit-textbook .action-upload", require_notification=False)
|
||||
self.wait_for_element_visibility(".upload-dialog input", "Upload modal opened")
|
||||
file_input = self.q(css=".upload-dialog input").results[0]
|
||||
file_input.send_keys(file_path)
|
||||
click_css(self, ".wrapper-modal-window-assetupload .action-upload", require_notification=False)
|
||||
self.wait_for_element_absence(".modal-window-overlay", "Upload modal closed")
|
||||
|
||||
def click_textbook_submit_button(self):
|
||||
"""
|
||||
Submit the new textbook form and check if it is rendered properly.
|
||||
"""
|
||||
self.wait_for_element_visibility('#edit_textbook_form button[type="submit"]', 'Save button visibility')
|
||||
self.q(css='#edit_textbook_form button[type="submit"]').first.click()
|
||||
self.wait_for_element_absence(".wrapper-form", "Add/Edit form closed")
|
||||
|
||||
def is_view_live_link_worked(self):
|
||||
"""
|
||||
Check if the view live button of textbook is working fine.
|
||||
"""
|
||||
try:
|
||||
self.wait_for(lambda: len(self.q(css='.textbook a.view').attrs('href')) > 0, "href value present")
|
||||
response = requests.get(self.q(css='.textbook a.view').attrs('href')[0])
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False
|
||||
|
||||
return response.status_code == 200
|
||||
|
||||
def upload_new_textbook(self):
|
||||
"""
|
||||
Fills out form to upload a new textbook
|
||||
"""
|
||||
self.open_add_textbook_form()
|
||||
self.upload_pdf_file('textbook.pdf')
|
||||
self.set_input_field_value('.edit-textbook #textbook-name-input', 'book_1')
|
||||
self.set_input_field_value('.edit-textbook #chapter1-name', 'chap_1')
|
||||
self.click_textbook_submit_button()
|
||||
|
||||
def set_textbook_name(self, textbook_name):
|
||||
"""
|
||||
Set the name of textbook.
|
||||
"""
|
||||
self.open_add_textbook_form()
|
||||
self.set_input_field_value('.edit-textbook #textbook-name-input', textbook_name)
|
||||
|
||||
def fill_chapter_name(self, ordinal, chapter_name):
|
||||
"""
|
||||
Adds chapter name by taking the ordinal of the chapter.
|
||||
"""
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
self.set_input_field_value(u'.textbook .chapter{i} input.chapter-name'.format(i=index + 1), chapter_name)
|
||||
|
||||
def fill_chapter_asset(self, ordinal, chapter_asset):
|
||||
"""
|
||||
Adds chapter asset by taking the ordinal of the chapter.
|
||||
"""
|
||||
index = ["first", "second", "third"].index(ordinal)
|
||||
self.set_input_field_value(u'.textbook .chapter{i} input.chapter-asset-path'.format(i=index + 1), chapter_asset)
|
||||
|
||||
def submit_chapter(self):
|
||||
"""
|
||||
Click on Add Chapter button.
|
||||
"""
|
||||
self.q(css='.action.action-add-chapter').first.click()
|
||||
|
||||
@property
|
||||
def textbook_name(self):
|
||||
"""
|
||||
Gets the name of a saved textbook.
|
||||
"""
|
||||
return self.q(css='.textbook-title').text[0]
|
||||
|
||||
def get_chapter_name(self, index):
|
||||
"""
|
||||
Gets the name of chapter by taking an ordinal.
|
||||
"""
|
||||
return self.q(css='.chapter-name').text[index]
|
||||
|
||||
def get_asset_name(self, index):
|
||||
"""
|
||||
Gets the name of chapter asset by taking an ordinal.
|
||||
"""
|
||||
return self.q(css='.chapter-asset-path').text[index]
|
||||
|
||||
def toggle_chapters(self):
|
||||
"""
|
||||
Toggle saved chapters.
|
||||
"""
|
||||
self.q(css='.chapter-toggle.show-chapters').click()
|
||||
|
||||
@property
|
||||
def number_of_chapters(self):
|
||||
"""
|
||||
Gets the total number of saved chapters.
|
||||
"""
|
||||
return len(self.q(css='.chapter').results)
|
||||
|
||||
def refresh_and_wait_for_load(self):
|
||||
"""
|
||||
Refresh the page and wait for all resources to load.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
self.wait_for_page()
|
||||
@@ -2,17 +2,9 @@
|
||||
Page classes to test either the Course Team page or the Library Team page.
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
|
||||
import six
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
|
||||
from common.test.acceptance.pages.studio import BASE_URL
|
||||
from common.test.acceptance.pages.studio.course_page import CoursePage
|
||||
from common.test.acceptance.pages.studio.utils import HelpMixin
|
||||
from common.test.acceptance.tests.helpers import disable_animations
|
||||
|
||||
|
||||
@@ -39,103 +31,6 @@ class UsersPageMixin(PageObject):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Returns True if the browser has loaded the page.
|
||||
"""
|
||||
return self.q(css='body.view-team').present and not self.q(css='.ui-loading').present
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
"""
|
||||
Return a list of users listed on this page.
|
||||
"""
|
||||
return self.q(css='.user-list .user-item').map(
|
||||
lambda el: UserWrapper(self.browser, el.get_attribute('data-email'))
|
||||
).results
|
||||
|
||||
@property
|
||||
def usernames(self):
|
||||
"""
|
||||
Returns a list of user names for users listed on this page
|
||||
"""
|
||||
return [user.name for user in self.users]
|
||||
|
||||
@property
|
||||
def has_add_button(self):
|
||||
"""
|
||||
Is the "New Team Member" button present?
|
||||
"""
|
||||
return self.q(css='.create-user-button').present
|
||||
|
||||
def click_add_button(self):
|
||||
"""
|
||||
Click on the "New Team Member" button
|
||||
"""
|
||||
self.q(css='.create-user-button').first.click()
|
||||
self.wait_for(lambda: self.new_user_form_visible, "Add user form is visible")
|
||||
|
||||
@property
|
||||
def new_user_form_visible(self):
|
||||
""" Is the new user form visible? """
|
||||
return self.q(css='.form-create.create-user .user-email-input').visible
|
||||
|
||||
def set_new_user_email(self, email):
|
||||
""" Set the value of the "New User Email Address" field. """
|
||||
self.q(css='.form-create.create-user .user-email-input').fill(email)
|
||||
|
||||
def click_submit_new_user_form(self):
|
||||
""" Submit the "New User" form """
|
||||
self.q(css='.form-create.create-user .action-primary').click()
|
||||
wait_for_ajax_or_reload(self.browser)
|
||||
self.wait_for_element_visibility('.user-list', 'wait for team to load')
|
||||
|
||||
def get_user(self, email):
|
||||
""" Gets user wrapper by email """
|
||||
target_users = [user for user in self.users if user.email == email]
|
||||
assert len(target_users) == 1
|
||||
return target_users[0]
|
||||
|
||||
def add_user_to_course(self, email):
|
||||
""" Adds user to a course/library """
|
||||
self.wait_for_element_visibility('.create-user-button', "Add team member button is available")
|
||||
self.click_add_button()
|
||||
self.set_new_user_email(email)
|
||||
self.click_submit_new_user_form()
|
||||
self.wait_for_page()
|
||||
|
||||
def delete_user_from_course(self, email):
|
||||
""" Deletes user from course/library """
|
||||
target_user = self.get_user(email)
|
||||
target_user.click_delete()
|
||||
self.wait_for_page()
|
||||
|
||||
def modal_dialog_visible(self, dialog_type):
|
||||
""" Checks if modal dialog of specified class is displayed """
|
||||
return self.q(css='.prompt.{dialog_type}'.format(dialog_type=dialog_type)).visible
|
||||
|
||||
def modal_dialog_text(self, dialog_type):
|
||||
""" Gets modal dialog text """
|
||||
return self.q(css=u'.prompt.{dialog_type} .message'.format(dialog_type=dialog_type)).text[0]
|
||||
|
||||
def wait_until_no_loading_indicator(self):
|
||||
"""
|
||||
When the page first loads, there is a loading indicator and most
|
||||
functionality is not yet available. This waits for that loading to finish
|
||||
and be removed from the DOM.
|
||||
|
||||
This method is different from wait_until_ready because the loading element
|
||||
is removed from the DOM, rather than hidden.
|
||||
|
||||
It also disables animations for improved test reliability.
|
||||
"""
|
||||
|
||||
self.wait_for(
|
||||
lambda: not self.q(css='.ui-loading').present,
|
||||
"Wait for page to complete its initial loading"
|
||||
)
|
||||
disable_animations(self)
|
||||
|
||||
def wait_until_ready(self):
|
||||
"""
|
||||
When the page first loads, there is a loading indicator and most
|
||||
@@ -153,145 +48,3 @@ class UsersPageMixin(PageObject):
|
||||
'Wait for the page to complete its initial loading'
|
||||
)
|
||||
disable_animations(self)
|
||||
|
||||
|
||||
class LibraryUsersPage(UsersPageMixin, HelpMixin):
|
||||
"""
|
||||
Library Team page in Studio
|
||||
"""
|
||||
def __init__(self, browser, locator):
|
||||
super(LibraryUsersPage, self).__init__(browser)
|
||||
self.locator = locator
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
URL to the "User Access" page for the given library.
|
||||
"""
|
||||
return "{}/library/{}/team/".format(BASE_URL, six.text_type(self.locator))
|
||||
|
||||
|
||||
class CourseTeamPage(UsersPageMixin, CoursePage):
|
||||
"""
|
||||
Course Team page in Studio.
|
||||
"""
|
||||
url_path = "course_team"
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""
|
||||
Construct a URL to the page within the course.
|
||||
"""
|
||||
# TODO - is there a better way to make this agnostic to the underlying default module store?
|
||||
default_store = os.environ.get('DEFAULT_STORE', 'draft')
|
||||
course_key = CourseLocator(
|
||||
self.course_info['course_org'],
|
||||
self.course_info['course_num'],
|
||||
self.course_info['course_run'],
|
||||
deprecated=(default_store == 'draft')
|
||||
)
|
||||
return "/".join([BASE_URL, self.url_path, six.text_type(course_key)])
|
||||
|
||||
|
||||
class UserWrapper(PageObject):
|
||||
"""
|
||||
A PageObject representing a wrapper around a user listed on the course/library team page.
|
||||
"""
|
||||
url = None
|
||||
COMPONENT_BUTTONS = {
|
||||
'basic_tab': '.editor-tabs li.inner_tab_wrap:nth-child(1) > a',
|
||||
'advanced_tab': '.editor-tabs li.inner_tab_wrap:nth-child(2) > a',
|
||||
'save_settings': '.action-save',
|
||||
}
|
||||
|
||||
def __init__(self, browser, email):
|
||||
super(UserWrapper, self).__init__(browser)
|
||||
self.email = email
|
||||
self.selector = u'.user-list .user-item[data-email="{}"]'.format(self.email)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""
|
||||
Sanity check that our wrapper element is on the page.
|
||||
"""
|
||||
return self.q(css=self.selector).present
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular user entry's context
|
||||
"""
|
||||
return u'{} {}'.format(self.selector, selector)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Get this user's username, as displayed. """
|
||||
text = self.q(css=self._bounded_selector('.user-username')).text
|
||||
return text[0] if text else None
|
||||
|
||||
@property
|
||||
def role_label(self):
|
||||
""" Get this user's role, as displayed. """
|
||||
text = self.q(css=self._bounded_selector('.flag-role .value')).text
|
||||
return text[0] if text else None
|
||||
|
||||
@property
|
||||
def is_current_user(self):
|
||||
""" Does the UI indicate that this is the current user? """
|
||||
return self.q(css=self._bounded_selector('.flag-role .msg-you')).present
|
||||
|
||||
@property
|
||||
def can_promote(self):
|
||||
""" Can this user be promoted to a more powerful role? """
|
||||
return self.q(css=self._bounded_selector('.add-admin-role')).present
|
||||
|
||||
@property
|
||||
def promote_button_text(self):
|
||||
""" What does the promote user button say? """
|
||||
text = self.q(css=self._bounded_selector('.add-admin-role')).text
|
||||
return text[0] if text else None
|
||||
|
||||
def click_promote(self):
|
||||
""" Click on the button to promote this user to the more powerful role """
|
||||
self.q(css=self._bounded_selector('.add-admin-role')).click()
|
||||
wait_for_ajax_or_reload(self.browser)
|
||||
|
||||
@property
|
||||
def can_demote(self):
|
||||
""" Can this user be demoted to a less powerful role? """
|
||||
return self.q(css=self._bounded_selector('.remove-admin-role')).present
|
||||
|
||||
@property
|
||||
def demote_button_text(self):
|
||||
""" What does the demote user button say? """
|
||||
text = self.q(css=self._bounded_selector('.remove-admin-role')).text
|
||||
return text[0] if text else None
|
||||
|
||||
def click_demote(self):
|
||||
""" Click on the button to demote this user to the less powerful role """
|
||||
self.q(css=self._bounded_selector('.remove-admin-role')).click()
|
||||
wait_for_ajax_or_reload(self.browser)
|
||||
|
||||
@property
|
||||
def can_delete(self):
|
||||
""" Can this user be deleted? """
|
||||
return self.q(css=self._bounded_selector('.action-delete:not(.is-disabled) .remove-user')).present
|
||||
|
||||
def click_delete(self):
|
||||
""" Click the button to delete this user. """
|
||||
disable_animations(self)
|
||||
self.q(css=self._bounded_selector('.remove-user')).click()
|
||||
# We can't use confirm_prompt because its wait_for_ajax is flaky when the page is expected to reload.
|
||||
self.wait_for_element_visibility('.prompt', 'Prompt is visible')
|
||||
self.wait_for_element_visibility('.prompt .action-primary', 'Confirmation button is visible')
|
||||
self.q(css='.prompt .action-primary').click()
|
||||
self.wait_for_element_absence('.page-prompt .is-shown', 'Confirmation prompt is hidden')
|
||||
wait_for_ajax_or_reload(self.browser)
|
||||
|
||||
@property
|
||||
def has_no_change_warning(self):
|
||||
""" Does this have a warning in place of the promote/demote buttons? """
|
||||
return self.q(css=self._bounded_selector('.notoggleforyou')).present
|
||||
|
||||
@property
|
||||
def no_change_warning_text(self):
|
||||
""" Text of the warning seen in place of the promote/demote buttons. """
|
||||
return self.q(css=self._bounded_selector('.notoggleforyou')).text[0]
|
||||
|
||||
@@ -4,11 +4,8 @@ Utility methods useful for Studio page tests.
|
||||
|
||||
|
||||
from bok_choy.javascript import js_defined
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css, sync_on_notification
|
||||
from common.test.acceptance.tests.helpers import click_and_wait_for_window
|
||||
|
||||
NAV_HELP_NOT_SIGNED_IN_CSS = '.nav-item.nav-not-signedin-help a'
|
||||
@@ -17,138 +14,6 @@ SIDE_BAR_HELP_AS_LIST_ITEM = '.bit li.action-item a'
|
||||
SIDE_BAR_HELP_CSS = '.external-help a, .external-help-button'
|
||||
|
||||
|
||||
@js_defined('window.jQuery')
|
||||
def press_the_notification_button(page, name):
|
||||
# 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 = u'div#page-notification button.action-%s' % name.lower()
|
||||
page.browser.execute_script(u"$('{}').focus().click()".format(btn_css))
|
||||
page.wait_for_ajax()
|
||||
|
||||
|
||||
def add_discussion(page, menu_index=0):
|
||||
"""
|
||||
Add a new instance of the discussion category.
|
||||
|
||||
menu_index specifies which instance of the menus should be used (based on vertical
|
||||
placement within the page).
|
||||
"""
|
||||
page.wait_for_component_menu()
|
||||
click_css(page, 'button>span.large-discussion-icon', menu_index)
|
||||
|
||||
|
||||
def add_advanced_component(page, menu_index, name):
|
||||
"""
|
||||
Adds an instance of the advanced component with the specified name.
|
||||
|
||||
menu_index specifies which instance of the menus should be used (based on vertical
|
||||
placement within the page).
|
||||
"""
|
||||
# Click on the Advanced icon.
|
||||
page.wait_for_component_menu()
|
||||
click_css(page, 'button>span.large-advanced-icon', menu_index, require_notification=False)
|
||||
|
||||
# This does an animation to hide the first level of buttons
|
||||
# and instead show the Advanced buttons that are available.
|
||||
# We should be OK though because click_css turns off jQuery animations
|
||||
|
||||
# Make sure that the menu of advanced components is visible before clicking (the HTML is always on the
|
||||
# page, but will have display none until the large-advanced-icon is clicked).
|
||||
page.wait_for_element_visibility('.new-component-advanced', 'Advanced component menu is visible')
|
||||
|
||||
# Now click on the component to add it.
|
||||
component_css = u'button[data-category={}]'.format(name)
|
||||
page.wait_for_element_visibility(component_css, u'Advanced component {} is visible'.format(name))
|
||||
|
||||
# Adding some components, e.g. the Discussion component, will make an ajax call
|
||||
# but we should be OK because the click_css method is written to handle that.
|
||||
click_css(page, component_css, 0)
|
||||
|
||||
|
||||
def add_component(page, item_type, specific_type, is_advanced_problem=False):
|
||||
"""
|
||||
Click one of the "Add New Component" buttons.
|
||||
|
||||
item_type should be "advanced", "html", "problem", or "video"
|
||||
|
||||
specific_type is required for some types and should be something like
|
||||
"Blank Common Problem".
|
||||
"""
|
||||
btn = page.q(css=u'.add-xblock-component .add-xblock-component-button[data-type={}]'.format(item_type))
|
||||
multiple_templates = btn.filter(lambda el: 'multiple-templates' in el.get_attribute('class')).present
|
||||
btn.click()
|
||||
if multiple_templates:
|
||||
sub_template_menu_div_selector = u'.new-component-{}'.format(item_type)
|
||||
page.wait_for_element_visibility(sub_template_menu_div_selector, 'Wait for the templates sub-menu to appear')
|
||||
page.wait_for_element_invisibility(
|
||||
'.add-xblock-component .new-component',
|
||||
'Wait for the add component menu to disappear'
|
||||
)
|
||||
|
||||
# "Common Problem Types" are shown by default.
|
||||
# For advanced problem types you must first select the "Advanced" tab.
|
||||
if is_advanced_problem:
|
||||
advanced_tab = page.q(css='.problem-type-tabs a').filter(text='Advanced').first
|
||||
advanced_tab.click()
|
||||
|
||||
# Wait for the advanced tab to be active
|
||||
css = '.problem-type-tabs li.ui-tabs-active a'
|
||||
page.wait_for(
|
||||
lambda: len(page.q(css=css).filter(text=u'Advanced').execute()) > 0,
|
||||
'Waiting for the Advanced problem tab to be active'
|
||||
)
|
||||
|
||||
all_options = page.q(css=u'.new-component-{} ul.new-component-template li button span'.format(item_type))
|
||||
chosen_option = all_options.filter(text=specific_type).first
|
||||
chosen_option.click()
|
||||
sync_on_notification(page)
|
||||
page.wait_for_ajax()
|
||||
|
||||
|
||||
def add_components(page, item_type, items, is_advanced_problem=False):
|
||||
"""
|
||||
Adds multiple components of a specific type.
|
||||
item_type should be "advanced", "html", "problem", or "video"
|
||||
items is a list of components of specific type to be added.
|
||||
Please note that if you want to create an advanced problem
|
||||
then all other items must be of advanced problem type.
|
||||
"""
|
||||
for item in items:
|
||||
add_component(page, item_type, item, is_advanced_problem)
|
||||
|
||||
|
||||
def add_html_component(page, menu_index, boilerplate=None):
|
||||
"""
|
||||
Adds an instance of the HTML component with the specified name.
|
||||
|
||||
menu_index specifies which instance of the menus should be used (based on vertical
|
||||
placement within the page).
|
||||
"""
|
||||
# Click on the HTML icon.
|
||||
page.wait_for_component_menu()
|
||||
click_css(page, 'button>span.large-html-icon', menu_index, require_notification=False)
|
||||
|
||||
# Make sure that the menu of HTML components is visible before clicking
|
||||
page.wait_for_element_visibility('.new-component-html', 'HTML component menu is visible')
|
||||
|
||||
# Now click on the component to add it.
|
||||
component_css = u'button[data-category=html]'
|
||||
if boilerplate:
|
||||
component_css += u'[data-boilerplate={}]'.format(boilerplate)
|
||||
else:
|
||||
component_css += u':not([data-boilerplate])'
|
||||
|
||||
page.wait_for_element_visibility(component_css, u'HTML component {} is visible'.format(boilerplate))
|
||||
|
||||
# Adding some components will make an ajax call but we should be OK because
|
||||
# the click_css method is written to handle that.
|
||||
click_css(page, component_css, 0)
|
||||
|
||||
|
||||
@js_defined('window.jQuery')
|
||||
def type_in_codemirror(page, index, text, find_prefix="$"):
|
||||
script = u"""
|
||||
@@ -169,17 +34,6 @@ def get_codemirror_value(page, index=0, find_prefix="$"):
|
||||
)
|
||||
|
||||
|
||||
def get_input_value(page, css_selector):
|
||||
"""
|
||||
Returns the value of the field matching the css selector.
|
||||
"""
|
||||
page.wait_for_element_presence(
|
||||
css_selector,
|
||||
u'Elements matching "{}" selector are present'.format(css_selector)
|
||||
)
|
||||
return page.q(css=css_selector).attrs('value')[0]
|
||||
|
||||
|
||||
def set_input_value(page, css, value):
|
||||
"""
|
||||
Sets the text field with the given label (display name) to the specified value.
|
||||
@@ -202,28 +56,6 @@ def set_input_value_and_save(page, css, value):
|
||||
page.wait_for_ajax()
|
||||
|
||||
|
||||
def drag(page, source_index, target_index, placeholder_height=0):
|
||||
"""
|
||||
Gets the drag handle with index source_index (relative to the vertical layout of the page)
|
||||
and drags it to the location of the drag handle with target_index.
|
||||
|
||||
This should drag the element with the source_index drag handle BEFORE the
|
||||
one with the target_index drag handle.
|
||||
"""
|
||||
draggables = page.q(css='.drag-handle')
|
||||
source = draggables[source_index]
|
||||
target = draggables[target_index]
|
||||
action = ActionChains(page.browser)
|
||||
action.click_and_hold(source).move_to_element_with_offset(
|
||||
target, 0, placeholder_height
|
||||
)
|
||||
if placeholder_height == 0:
|
||||
action.release(target).perform()
|
||||
else:
|
||||
action.release().perform()
|
||||
sync_on_notification(page)
|
||||
|
||||
|
||||
def verify_ordering(test_class, page, expected_orderings):
|
||||
"""
|
||||
Verifies the expected ordering of xblocks on the page.
|
||||
@@ -247,27 +79,6 @@ def verify_ordering(test_class, page, expected_orderings):
|
||||
test_class.assertEqual(len(blocks_checked), len(xblocks))
|
||||
|
||||
|
||||
def click_studio_help(page):
|
||||
"""
|
||||
Click the Studio help link in the page footer.
|
||||
"""
|
||||
help_link_selector = '.cta-show-sock'
|
||||
# check if help link is visible
|
||||
EmptyPromise(lambda: page.q(css=help_link_selector).visible, "Help link visible").fulfill()
|
||||
|
||||
page.q(css=help_link_selector).click()
|
||||
|
||||
# check if extended support section is visible.
|
||||
EmptyPromise(
|
||||
lambda: page.q(css='.support .list-actions a').results[0].text != '', 'Support section opened'
|
||||
).fulfill()
|
||||
|
||||
|
||||
def studio_help_links(page):
|
||||
"""Return the list of Studio help links in the page footer."""
|
||||
return page.q(css='.support .list-actions a').results
|
||||
|
||||
|
||||
class HelpMixin(object):
|
||||
"""
|
||||
Mixin for testing Help links.
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
"""
|
||||
Acceptance test xblock-editor.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support.ui import Select
|
||||
|
||||
from common.test.acceptance.pages.common.utils import click_css
|
||||
from common.test.acceptance.tests.helpers import get_selected_option_text, select_option_by_text
|
||||
|
||||
|
||||
class BaseXBlockEditorView(PageObject):
|
||||
"""
|
||||
A base :class:`.PageObject` for the xblock and visibility editors.
|
||||
|
||||
This class assumes that the editor is our default editor as displayed for xmodules.
|
||||
"""
|
||||
BODY_SELECTOR = '.xblock-editor'
|
||||
|
||||
def __init__(self, browser, locator):
|
||||
"""
|
||||
Args:
|
||||
browser (selenium.webdriver): The Selenium-controlled browser that this page is loaded in.
|
||||
locator (str): The locator that identifies which xblock this :class:`.xblock-editor` relates to.
|
||||
"""
|
||||
super(BaseXBlockEditorView, self).__init__(browser)
|
||||
self.locator = locator
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='{}[data-locator="{}"]'.format(self.BODY_SELECTOR, self.locator)).present
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular `XBlockEditorView` context
|
||||
"""
|
||||
return u'{}[data-locator="{}"] {}'.format(
|
||||
self.BODY_SELECTOR,
|
||||
self.locator,
|
||||
selector
|
||||
)
|
||||
|
||||
def url(self):
|
||||
"""
|
||||
Returns None because this is not directly accessible via URL.
|
||||
"""
|
||||
return None
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
click_css(self, 'a.action-save')
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Clicks cancel button.
|
||||
"""
|
||||
click_css(self, 'a.action-cancel', require_notification=False)
|
||||
|
||||
|
||||
class XBlockEditorView(BaseXBlockEditorView):
|
||||
"""
|
||||
A :class:`.PageObject` representing the rendered view of an xblock editor.
|
||||
"""
|
||||
def get_setting_element(self, label):
|
||||
"""
|
||||
Returns the index of the setting entry with given label (display name) within the Settings modal.
|
||||
"""
|
||||
settings_button = self.q(css='.edit-xblock-modal .editor-modes .settings-button')
|
||||
if settings_button.is_present():
|
||||
settings_button.click()
|
||||
setting_labels = self.q(css=self._bounded_selector('.metadata_edit .wrapper-comp-setting .setting-label'))
|
||||
for index, setting in enumerate(setting_labels):
|
||||
if setting.text == label:
|
||||
return self.q(css=self._bounded_selector('.metadata_edit div.wrapper-comp-setting .setting-input'))[index]
|
||||
return None
|
||||
|
||||
def set_field_value_and_save(self, label, value):
|
||||
"""
|
||||
Sets the text field with given label (display name) to the specified value, and presses Save.
|
||||
"""
|
||||
elem = self.get_setting_element(label)
|
||||
|
||||
# Clear the current value, set the new one, then
|
||||
# Tab to move to the next field (so change event is triggered).
|
||||
elem.clear()
|
||||
elem.send_keys(value)
|
||||
elem.send_keys(Keys.TAB)
|
||||
self.save()
|
||||
|
||||
def set_select_value_and_save(self, label, value):
|
||||
"""
|
||||
Sets the select with given label (display name) to the specified value, and presses Save.
|
||||
"""
|
||||
elem = self.get_setting_element(label)
|
||||
select = Select(elem)
|
||||
select.select_by_value(value)
|
||||
self.save()
|
||||
|
||||
def get_selected_option_text(self, label):
|
||||
"""
|
||||
Returns the text of the first selected option for the select with given label (display name).
|
||||
"""
|
||||
elem = self.get_setting_element(label)
|
||||
if elem:
|
||||
select = Select(elem)
|
||||
return select.first_selected_option.text
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class XBlockVisibilityEditorView(BaseXBlockEditorView):
|
||||
"""
|
||||
A :class:`.PageObject` representing the rendered view of an xblock visibility editor.
|
||||
"""
|
||||
OPTION_SELECTOR = '.partition-group-control .field'
|
||||
ALL_LEARNERS_AND_STAFF = 'All Learners and Staff'
|
||||
CONTENT_GROUP_PARTITION = 'Content Groups'
|
||||
ENROLLMENT_TRACK_PARTITION = "Enrollment Track Groups"
|
||||
|
||||
@property
|
||||
def all_group_options(self):
|
||||
"""
|
||||
Return all partition groups.
|
||||
"""
|
||||
return self.q(css=self._bounded_selector(self.OPTION_SELECTOR)).results
|
||||
|
||||
@property
|
||||
def current_groups_message(self):
|
||||
"""
|
||||
This returns the message shown at the top of the visibility dialog about the
|
||||
current visibility state (at the time that the dialog was opened).
|
||||
For example, "Access is restricted to: All Learners and Staff".
|
||||
"""
|
||||
return self.q(css=self._bounded_selector('.visibility-header'))[0].text
|
||||
|
||||
@property
|
||||
def selected_partition_scheme(self):
|
||||
"""
|
||||
Return the selected partition scheme (or "All Learners and Staff"
|
||||
if no partitioning is selected).
|
||||
"""
|
||||
selector = self.q(css=self._bounded_selector('.partition-visibility select'))
|
||||
return get_selected_option_text(selector)
|
||||
|
||||
def select_partition_scheme(self, partition_name):
|
||||
"""
|
||||
Sets the selected partition scheme to the one with the
|
||||
matching name.
|
||||
"""
|
||||
selector = self.q(css=self._bounded_selector('.partition-visibility select'))
|
||||
select_option_by_text(selector, partition_name, focus_out=True)
|
||||
|
||||
@property
|
||||
def selected_groups(self):
|
||||
"""
|
||||
Return all selected partition groups. If none are selected,
|
||||
returns an empty array.
|
||||
"""
|
||||
results = []
|
||||
for option in self.all_group_options:
|
||||
checkbox = option.find_element_by_css_selector('input')
|
||||
if checkbox.is_selected():
|
||||
results.append(option)
|
||||
return results
|
||||
|
||||
def select_group(self, group_name, save=True):
|
||||
"""
|
||||
Select the first group which has a label matching `group_name`.
|
||||
|
||||
Arguments:
|
||||
group_name (str): The name of the group.
|
||||
save (boolean): Whether the "save" button should be clicked
|
||||
afterwards.
|
||||
Returns:
|
||||
bool: Whether a group with the provided name was found and clicked.
|
||||
"""
|
||||
for option in self.all_group_options:
|
||||
if group_name in option.text:
|
||||
checkbox = option.find_element_by_css_selector('input')
|
||||
checkbox.click()
|
||||
if save:
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def select_groups_in_partition_scheme(self, partition_name, group_names):
|
||||
"""
|
||||
Select groups in the provided partition scheme. The "save"
|
||||
button is clicked afterwards.
|
||||
"""
|
||||
self.select_partition_scheme(partition_name)
|
||||
for label in group_names:
|
||||
self.select_group(label, save=False)
|
||||
self.save()
|
||||
@@ -1,100 +0,0 @@
|
||||
"""
|
||||
PageObjects related to the AcidBlock
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import BrokenPromise, EmptyPromise
|
||||
|
||||
from common.test.acceptance.pages.xblock.utils import wait_for_xblock_initialization
|
||||
|
||||
|
||||
class AcidView(PageObject):
|
||||
"""
|
||||
A :class:`.PageObject` representing the rendered view of the :class:`.AcidBlock`.
|
||||
"""
|
||||
url = None
|
||||
|
||||
def __init__(self, browser, context_selector):
|
||||
"""
|
||||
Args:
|
||||
browser (selenium.webdriver): The Selenium-controlled browser that this page is loaded in.
|
||||
context_selector (str): The selector that identifies where this :class:`.AcidBlock` view
|
||||
is on the page.
|
||||
"""
|
||||
super(AcidView, self).__init__(browser)
|
||||
self.context_selector = context_selector
|
||||
|
||||
def is_browser_on_page(self):
|
||||
|
||||
# First make sure that an element with the view-container class is present on the page,
|
||||
# and then wait to make sure that the xblock has finished initializing.
|
||||
return (
|
||||
self.q(css=u'{} .acid-block'.format(self.context_selector)).present and
|
||||
wait_for_xblock_initialization(self, self.context_selector) and
|
||||
self._ajax_finished()
|
||||
)
|
||||
|
||||
def _ajax_finished(self):
|
||||
try:
|
||||
EmptyPromise(
|
||||
lambda: self.browser.execute_script("return jQuery.active") == 0,
|
||||
"AcidBlock tests still running",
|
||||
timeout=240
|
||||
).fulfill()
|
||||
except BrokenPromise:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def test_passed(self, test_selector):
|
||||
"""
|
||||
Return whether a particular :class:`.AcidBlock` test passed.
|
||||
"""
|
||||
selector = u'{} .acid-block {} .pass'.format(self.context_selector, test_selector)
|
||||
return bool(self.q(css=selector).results)
|
||||
|
||||
def child_test_passed(self, test_selector):
|
||||
"""
|
||||
Return whether a particular :class:`.AcidParentBlock` test passed.
|
||||
"""
|
||||
selector = u'{} .acid-parent-block {} .pass'.format(self.context_selector, test_selector)
|
||||
return bool(self.q(css=selector).execute(try_interval=0.1, timeout=3))
|
||||
|
||||
@property
|
||||
def init_fn_passed(self):
|
||||
"""
|
||||
Whether the init-fn test passed in this view of the :class:`.AcidBlock`.
|
||||
"""
|
||||
return self.test_passed('.js-init-run')
|
||||
|
||||
@property
|
||||
def child_tests_passed(self):
|
||||
"""
|
||||
Whether the tests of children passed
|
||||
"""
|
||||
return all([
|
||||
self.child_test_passed('.child-counts-match'),
|
||||
self.child_test_passed('.child-values-match')
|
||||
])
|
||||
|
||||
@property
|
||||
def resource_url_passed(self):
|
||||
"""
|
||||
Whether the resource-url test passed in this view of the :class:`.AcidBlock`.
|
||||
"""
|
||||
return self.test_passed('.local-resource-test')
|
||||
|
||||
def scope_passed(self, scope):
|
||||
return all(
|
||||
self.test_passed(u'.scope-storage-test.scope-{} {}'.format(scope, test))
|
||||
for test in (
|
||||
".server-storage-test-returned",
|
||||
".server-storage-test-succeeded",
|
||||
".client-storage-test-returned",
|
||||
".client-storage-test-succeeded",
|
||||
)
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return "{}(<browser>, {!r})".format(self.__class__.__name__, self.context_selector)
|
||||
@@ -1,18 +0,0 @@
|
||||
"""
|
||||
Utility methods useful for XBlock page tests.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.promise import Promise
|
||||
|
||||
|
||||
def wait_for_xblock_initialization(page, xblock_css):
|
||||
"""
|
||||
Wait for the xblock with the given CSS to finish initializing.
|
||||
"""
|
||||
def _is_finished_loading():
|
||||
# Wait for the xblock javascript to finish initializing
|
||||
is_done = page.browser.execute_script(u"return $('{}').data('initialized')".format(xblock_css))
|
||||
return is_done, is_done
|
||||
|
||||
return Promise(_is_finished_loading, 'Finished initializing the xblock.').fulfill()
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Install bok-choy page objects for acceptance and end-to-end tests.
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
VERSION = '0.0.1'
|
||||
DESCRIPTION = "Bok-choy page objects for edx-platform"
|
||||
|
||||
# Pip 1.5 will try to install this package from outside
|
||||
# the directory containing setup.py, so we need to use an absolute path.
|
||||
ACCEPTANCE_PACKAGE_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
setup(
|
||||
name='edxapp-acceptance',
|
||||
version=VERSION,
|
||||
author='edX',
|
||||
url='http://github.com/edx/edx-platform',
|
||||
description=DESCRIPTION,
|
||||
license='AGPL',
|
||||
classifiers=[
|
||||
'Development Status :: 3 - Alpha',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: GNU Affero General Public License v3',
|
||||
'Operating System :: OS Independent',
|
||||
'Programming Language :: Python',
|
||||
'Topic :: Software Development :: Testing',
|
||||
'Topic :: Software Development :: Quality Assurance'
|
||||
],
|
||||
package_dir={'edxapp_acceptance': ACCEPTANCE_PACKAGE_DIR},
|
||||
packages=['edxapp_acceptance',
|
||||
'edxapp_acceptance.pages',
|
||||
'edxapp_acceptance.pages.lms',
|
||||
'edxapp_acceptance.pages.studio',
|
||||
'edxapp_acceptance.pages.common',
|
||||
'edxapp_acceptance.tests']
|
||||
)
|
||||
@@ -13,9 +13,7 @@ from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureD
|
||||
from common.test.acceptance.fixtures.discussion import (
|
||||
ForumsConfigMixin,
|
||||
MultipleThreadFixture,
|
||||
Response,
|
||||
SingleThreadViewFixture,
|
||||
Thread
|
||||
Thread,
|
||||
)
|
||||
from common.test.acceptance.pages.lms.discussion import DiscussionTabSingleThreadPage
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest
|
||||
@@ -25,22 +23,6 @@ class BaseDiscussionMixin(object):
|
||||
"""
|
||||
A mixin containing methods common to discussion tests.
|
||||
"""
|
||||
def setup_thread(self, num_responses, **thread_kwargs):
|
||||
"""
|
||||
Create a test thread with the given number of responses, passing all
|
||||
keyword arguments through to the Thread fixture, then invoke
|
||||
setup_thread_page.
|
||||
"""
|
||||
thread_id = "test_thread_{}".format(uuid4().hex)
|
||||
thread_fixture = SingleThreadViewFixture(
|
||||
Thread(id=thread_id, commentable_id=self.discussion_id, **thread_kwargs)
|
||||
)
|
||||
for i in range(num_responses):
|
||||
thread_fixture.addResponse(Response(id=str(i), body=str(i)))
|
||||
response = thread_fixture.push()
|
||||
self.assertTrue(response.ok, "Failed to push discussion content")
|
||||
self.setup_thread_page(thread_id)
|
||||
return thread_id
|
||||
|
||||
def setup_multiple_threads(self, thread_count, **thread_kwargs):
|
||||
"""
|
||||
@@ -79,32 +61,6 @@ class CohortTestMixin(object):
|
||||
},
|
||||
})
|
||||
|
||||
def enable_cohorting(self, course_fixture):
|
||||
"""
|
||||
Enables cohorting for the specified course fixture.
|
||||
"""
|
||||
url = LMS_BASE_URL + "/courses/" + course_fixture._course_key + '/cohorts/settings'
|
||||
data = json.dumps({'is_cohorted': True})
|
||||
response = course_fixture.session.patch(url, data=data, headers=course_fixture.headers)
|
||||
self.assertTrue(response.ok, "Failed to enable cohorts")
|
||||
|
||||
def enable_always_divide_inline_discussions(self, course_fixture):
|
||||
"""
|
||||
Enables "always_divide_inline_discussions" (but does not enabling cohorting).
|
||||
"""
|
||||
discussions_url = LMS_BASE_URL + "/courses/" + course_fixture._course_key + '/discussions/settings'
|
||||
discussions_data = json.dumps({'always_divide_inline_discussions': True})
|
||||
course_fixture.session.patch(discussions_url, data=discussions_data, headers=course_fixture.headers)
|
||||
|
||||
def disable_cohorting(self, course_fixture):
|
||||
"""
|
||||
Disables cohorting for the specified course fixture.
|
||||
"""
|
||||
url = LMS_BASE_URL + "/courses/" + course_fixture._course_key + '/cohorts/settings'
|
||||
data = json.dumps({'is_cohorted': False})
|
||||
response = course_fixture.session.patch(url, data=data, headers=course_fixture.headers)
|
||||
self.assertTrue(response.ok, "Failed to disable cohorts")
|
||||
|
||||
def add_manual_cohort(self, course_fixture, cohort_name):
|
||||
"""
|
||||
Adds a cohort by name, returning its ID.
|
||||
|
||||
@@ -4,25 +4,14 @@ End-to-end tests related to the cohort management on the LMS Instructor Dashboar
|
||||
"""
|
||||
|
||||
|
||||
import csv
|
||||
import os
|
||||
import os.path
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import six
|
||||
import unicodecsv
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from pytz import UTC, utc
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.instructor_dashboard import DataDownloadPage, InstructorDashboardPage
|
||||
from common.test.acceptance.pages.studio.settings_group_configurations import GroupConfigurationsPage
|
||||
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage
|
||||
from common.test.acceptance.tests.discussion.helpers import CohortTestMixin
|
||||
from common.test.acceptance.tests.helpers import EventsTestMixin, UniqueCourseTest, create_user_partition_json
|
||||
from common.test.acceptance.tests.helpers import EventsTestMixin, UniqueCourseTest
|
||||
from openedx.core.lib.tests import attr
|
||||
from xmodule.partitions.partitions import Group
|
||||
|
||||
|
||||
@attr(shard=8)
|
||||
@@ -71,262 +60,6 @@ class CohortConfigurationTest(EventsTestMixin, UniqueCourseTest, CohortTestMixin
|
||||
self.instructor_dashboard_page.visit()
|
||||
self.cohort_management_page = self.instructor_dashboard_page.select_cohort_management()
|
||||
|
||||
def verify_cohort_description(self, cohort_name, expected_description):
|
||||
"""
|
||||
Selects the cohort with the given name and verifies the expected description is presented.
|
||||
"""
|
||||
self.cohort_management_page.select_cohort(cohort_name)
|
||||
self.assertEqual(self.cohort_management_page.get_selected_cohort(), cohort_name)
|
||||
self.assertIn(expected_description, self.cohort_management_page.get_cohort_group_setup())
|
||||
|
||||
def test_cohort_description(self):
|
||||
"""
|
||||
Scenario: the cohort configuration management in the instructor dashboard specifies whether
|
||||
students are automatically or manually assigned to specific cohorts.
|
||||
|
||||
Given I have a course with a manual cohort and an automatic cohort defined
|
||||
When I view the manual cohort in the instructor dashboard
|
||||
There is text specifying that students are only added to the cohort manually
|
||||
And when I view the automatic cohort in the instructor dashboard
|
||||
There is text specifying that students are automatically added to the cohort
|
||||
"""
|
||||
self.verify_cohort_description(
|
||||
self.manual_cohort_name,
|
||||
'Learners are added to this cohort only when you provide '
|
||||
'their email addresses or usernames on this page',
|
||||
)
|
||||
self.verify_cohort_description(
|
||||
self.auto_cohort_name,
|
||||
'Learners are added to this cohort automatically',
|
||||
)
|
||||
|
||||
def test_no_content_groups(self):
|
||||
"""
|
||||
Scenario: if the course has no content groups defined (user_partitions of type cohort),
|
||||
the settings in the cohort management tab reflect this
|
||||
|
||||
Given I have a course with a cohort defined but no content groups
|
||||
When I view the cohort in the instructor dashboard and select settings
|
||||
Then the cohort is not linked to a content group
|
||||
And there is text stating that no content groups are defined
|
||||
And I cannot select the radio button to enable content group association
|
||||
And there is a link I can select to open Group settings in Studio
|
||||
"""
|
||||
self.cohort_management_page.select_cohort(self.manual_cohort_name)
|
||||
self.assertIsNone(self.cohort_management_page.get_cohort_associated_content_group())
|
||||
self.assertEqual(
|
||||
"Warning:\nNo content groups exist. Create a content group",
|
||||
self.cohort_management_page.get_cohort_related_content_group_message()
|
||||
)
|
||||
self.assertFalse(self.cohort_management_page.select_content_group_radio_button())
|
||||
self.cohort_management_page.select_studio_group_settings()
|
||||
group_settings_page = GroupConfigurationsPage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
group_settings_page.wait_for_page()
|
||||
|
||||
def test_add_students_to_cohort_success(self):
|
||||
"""
|
||||
Scenario: When students are added to a cohort, the appropriate notification is shown.
|
||||
|
||||
Given I have a course with two cohorts
|
||||
And there is a user in one cohort
|
||||
And there is a user in neither cohort
|
||||
When I add the two users to the cohort that initially had no users
|
||||
Then there are 2 users in total in the cohort
|
||||
And I get a notification that 2 users have been added to the cohort
|
||||
And I get a notification that 1 user was moved from the other cohort
|
||||
And the user input field is empty
|
||||
And appropriate events have been emitted
|
||||
"""
|
||||
start_time = datetime.now(UTC)
|
||||
self.cohort_management_page.select_cohort(self.auto_cohort_name)
|
||||
self.assertEqual(0, self.cohort_management_page.get_selected_cohort_count())
|
||||
self.cohort_management_page.add_students_to_selected_cohort([self.student_name, self.instructor_name])
|
||||
# Wait for the number of users in the cohort to change, indicating that the add operation is complete.
|
||||
EmptyPromise(
|
||||
lambda: 2 == self.cohort_management_page.get_selected_cohort_count(), 'Waiting for added students'
|
||||
).fulfill()
|
||||
confirmation_messages = self.cohort_management_page.get_cohort_confirmation_messages()
|
||||
self.assertEqual(
|
||||
[
|
||||
"2 learners have been added to this cohort.",
|
||||
"1 learner was moved from " + self.manual_cohort_name
|
||||
],
|
||||
confirmation_messages
|
||||
)
|
||||
self.assertEqual("", self.cohort_management_page.get_cohort_student_input_field_value())
|
||||
self.assertEqual(
|
||||
self.event_collection.find({
|
||||
"name": "edx.cohort.user_added",
|
||||
"time": {"$gt": start_time},
|
||||
"event.user_id": {"$in": [int(self.instructor_id), int(self.student_id)]},
|
||||
"event.cohort_name": self.auto_cohort_name,
|
||||
}).count(),
|
||||
2
|
||||
)
|
||||
self.assertEqual(
|
||||
self.event_collection.find({
|
||||
"name": "edx.cohort.user_removed",
|
||||
"time": {"$gt": start_time},
|
||||
"event.user_id": int(self.student_id),
|
||||
"event.cohort_name": self.manual_cohort_name,
|
||||
}).count(),
|
||||
1
|
||||
)
|
||||
self.assertEqual(
|
||||
self.event_collection.find({
|
||||
"name": "edx.cohort.user_add_requested",
|
||||
"time": {"$gt": start_time},
|
||||
"event.user_id": int(self.instructor_id),
|
||||
"event.cohort_name": self.auto_cohort_name,
|
||||
"event.previous_cohort_name": None,
|
||||
}).count(),
|
||||
1
|
||||
)
|
||||
self.assertEqual(
|
||||
self.event_collection.find({
|
||||
"name": "edx.cohort.user_add_requested",
|
||||
"time": {"$gt": start_time},
|
||||
"event.user_id": int(self.student_id),
|
||||
"event.cohort_name": self.auto_cohort_name,
|
||||
"event.previous_cohort_name": self.manual_cohort_name,
|
||||
}).count(),
|
||||
1
|
||||
)
|
||||
|
||||
def test_add_students_to_cohort_failure(self):
|
||||
"""
|
||||
Scenario: When errors occur when adding students to a cohort, the appropriate notification is shown.
|
||||
|
||||
Given I have a course with a cohort and a user already in it
|
||||
When I add the user already in a cohort to that same cohort
|
||||
And I add a non-existing user to that cohort
|
||||
Then there is no change in the number of students in the cohort
|
||||
And I get a notification that one user was already in the cohort
|
||||
And I get a notification that one user is unknown
|
||||
And the user input field still contains the incorrect email addresses
|
||||
"""
|
||||
self.cohort_management_page.select_cohort(self.manual_cohort_name)
|
||||
self.assertEqual(1, self.cohort_management_page.get_selected_cohort_count())
|
||||
self.cohort_management_page.add_students_to_selected_cohort([self.student_name, "unknown_user"])
|
||||
# Wait for notification messages to appear, indicating that the add operation is complete.
|
||||
EmptyPromise(
|
||||
lambda: 2 == len(self.cohort_management_page.get_cohort_confirmation_messages()), 'Waiting for notification'
|
||||
).fulfill()
|
||||
self.assertEqual(1, self.cohort_management_page.get_selected_cohort_count())
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"0 learners have been added to this cohort.",
|
||||
"1 learner was already in the cohort"
|
||||
],
|
||||
self.cohort_management_page.get_cohort_confirmation_messages()
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"There was an error when trying to add learners:",
|
||||
"Unknown username: unknown_user"
|
||||
],
|
||||
self.cohort_management_page.get_cohort_error_messages()
|
||||
)
|
||||
self.assertEqual(
|
||||
self.student_name + ",unknown_user,",
|
||||
self.cohort_management_page.get_cohort_student_input_field_value()
|
||||
)
|
||||
|
||||
def _verify_cohort_settings(
|
||||
self,
|
||||
cohort_name,
|
||||
assignment_type=None,
|
||||
new_cohort_name=None,
|
||||
new_assignment_type=None,
|
||||
verify_updated=False
|
||||
):
|
||||
"""
|
||||
Create a new cohort and verify the new and existing settings.
|
||||
"""
|
||||
start_time = datetime.now(UTC)
|
||||
self.assertNotIn(cohort_name, self.cohort_management_page.get_cohorts())
|
||||
self.cohort_management_page.add_cohort(cohort_name, assignment_type=assignment_type)
|
||||
self.assertEqual(0, self.cohort_management_page.get_selected_cohort_count())
|
||||
# After adding the cohort, it should automatically be selected and its
|
||||
# assignment_type should be "manual" as this is the default assignment type
|
||||
_assignment_type = assignment_type or 'manual'
|
||||
msg = "Waiting for currently selected cohort assignment type"
|
||||
EmptyPromise(
|
||||
lambda: _assignment_type == self.cohort_management_page.get_cohort_associated_assignment_type(), msg
|
||||
).fulfill()
|
||||
# Go back to Manage Students Tab
|
||||
self.cohort_management_page.select_manage_settings()
|
||||
self.cohort_management_page.add_students_to_selected_cohort([self.instructor_name])
|
||||
# Wait for the number of users in the cohort to change, indicating that the add operation is complete.
|
||||
EmptyPromise(
|
||||
lambda: 1 == self.cohort_management_page.get_selected_cohort_count(), 'Waiting for student to be added'
|
||||
).fulfill()
|
||||
self.assertFalse(self.cohort_management_page.is_assignment_settings_disabled)
|
||||
self.assertEqual('', self.cohort_management_page.assignment_settings_message)
|
||||
self.assertEqual(
|
||||
self.event_collection.find({
|
||||
"name": "edx.cohort.created",
|
||||
"time": {"$gt": start_time},
|
||||
"event.cohort_name": cohort_name,
|
||||
}).count(),
|
||||
1
|
||||
)
|
||||
self.assertEqual(
|
||||
self.event_collection.find({
|
||||
"name": "edx.cohort.creation_requested",
|
||||
"time": {"$gt": start_time},
|
||||
"event.cohort_name": cohort_name,
|
||||
}).count(),
|
||||
1
|
||||
)
|
||||
|
||||
if verify_updated:
|
||||
self.cohort_management_page.select_cohort(cohort_name)
|
||||
self.cohort_management_page.select_cohort_settings()
|
||||
self.cohort_management_page.set_cohort_name(new_cohort_name)
|
||||
self.cohort_management_page.set_assignment_type(new_assignment_type)
|
||||
self.cohort_management_page.save_cohort_settings()
|
||||
|
||||
# If cohort name is empty, then we should get/see an error message.
|
||||
if not new_cohort_name:
|
||||
confirmation_messages = self.cohort_management_page.get_cohort_settings_messages(type='error')
|
||||
self.assertEqual(
|
||||
["The cohort cannot be saved", "You must specify a name for the cohort"],
|
||||
confirmation_messages
|
||||
)
|
||||
else:
|
||||
confirmation_messages = self.cohort_management_page.get_cohort_settings_messages()
|
||||
self.assertEqual(["Saved cohort"], confirmation_messages)
|
||||
self.assertEqual(new_cohort_name, self.cohort_management_page.cohort_name_in_header)
|
||||
self.assertIn(new_cohort_name, self.cohort_management_page.get_cohorts())
|
||||
self.assertEqual(1, self.cohort_management_page.get_selected_cohort_count())
|
||||
self.assertEqual(
|
||||
new_assignment_type,
|
||||
self.cohort_management_page.get_cohort_associated_assignment_type()
|
||||
)
|
||||
|
||||
def _create_csv_file(self, filename, csv_text_as_lists):
|
||||
"""
|
||||
Create a csv file with the provided list of lists.
|
||||
|
||||
:param filename: this is the name that will be used for the csv file. Its location will
|
||||
be under the test upload data directory
|
||||
:param csv_text_as_lists: provide the contents of the csv file int he form of a list of lists
|
||||
"""
|
||||
filename = self.instructor_dashboard_page.get_asset_path(filename)
|
||||
with open(filename, 'w+') as csv_file:
|
||||
writer = csv.writer(csv_file) if six.PY3 else unicodecsv.writer(csv_file)
|
||||
for line in csv_text_as_lists:
|
||||
writer.writerow(line)
|
||||
self.addCleanup(os.remove, filename)
|
||||
|
||||
def _generate_unique_user_data(self):
|
||||
"""
|
||||
Produce unique username and e-mail.
|
||||
@@ -335,344 +68,6 @@ class CohortConfigurationTest(EventsTestMixin, UniqueCourseTest, CohortTestMixin
|
||||
unique_email = unique_username + "@example.com"
|
||||
return unique_username, unique_email
|
||||
|
||||
def test_add_new_cohort_with_manual_assignment_type(self):
|
||||
"""
|
||||
Scenario: A new cohort with manual assignment type can be created, and a student assigned to it.
|
||||
|
||||
Given I have a course with a user in the course
|
||||
When I add a new manual cohort with manual assignment type to the course via the LMS instructor dashboard
|
||||
Then the new cohort is displayed and has no users in it
|
||||
And assignment type of displayed cohort is "manual"
|
||||
And when I add the user to the new cohort
|
||||
Then the cohort has 1 user
|
||||
And appropriate events have been emitted
|
||||
"""
|
||||
cohort_name = str(uuid.uuid4().hex[0:20])
|
||||
self._verify_cohort_settings(cohort_name=cohort_name, assignment_type='manual')
|
||||
|
||||
def test_add_new_cohort_with_random_assignment_type(self):
|
||||
"""
|
||||
Scenario: A new cohort with random assignment type can be created, and a student assigned to it.
|
||||
|
||||
Given I have a course with a user in the course
|
||||
When I add a new manual cohort with random assignment type to the course via the LMS instructor dashboard
|
||||
Then the new cohort is displayed and has no users in it
|
||||
And assignment type of displayed cohort is "random"
|
||||
And when I add the user to the new cohort
|
||||
Then the cohort has 1 user
|
||||
And appropriate events have been emitted
|
||||
"""
|
||||
cohort_name = str(uuid.uuid4().hex[0:20])
|
||||
self._verify_cohort_settings(cohort_name=cohort_name, assignment_type='random')
|
||||
|
||||
def test_update_existing_cohort_settings(self):
|
||||
"""
|
||||
Scenario: Update existing cohort settings(cohort name, assignment type)
|
||||
|
||||
Given I have a course with a user in the course
|
||||
When I add a new cohort with random assignment type to the course via the LMS instructor dashboard
|
||||
Then the new cohort is displayed and has no users in it
|
||||
And assignment type of displayed cohort is "random"
|
||||
And when I add the user to the new cohort
|
||||
Then the cohort has 1 user
|
||||
And appropriate events have been emitted
|
||||
Then I select the cohort (that you just created) from existing cohorts
|
||||
Then I change its name and assignment type set to "manual"
|
||||
Then I Save the settings
|
||||
And cohort with new name is present in cohorts dropdown list
|
||||
And cohort assignment type should be "manual"
|
||||
"""
|
||||
cohort_name = str(uuid.uuid4().hex[0:20])
|
||||
new_cohort_name = '{old}__NEW'.format(old=cohort_name)
|
||||
self._verify_cohort_settings(
|
||||
cohort_name=cohort_name,
|
||||
assignment_type='random',
|
||||
new_cohort_name=new_cohort_name,
|
||||
new_assignment_type='manual',
|
||||
verify_updated=True
|
||||
)
|
||||
|
||||
def test_update_existing_cohort_settings_with_empty_cohort_name(self):
|
||||
"""
|
||||
Scenario: Update existing cohort settings(cohort name, assignment type).
|
||||
|
||||
Given I have a course with a user in the course
|
||||
When I add a new cohort with random assignment type to the course via the LMS instructor dashboard
|
||||
Then the new cohort is displayed and has no users in it
|
||||
And assignment type of displayed cohort is "random"
|
||||
And when I add the user to the new cohort
|
||||
Then the cohort has 1 user
|
||||
And appropriate events have been emitted
|
||||
Then I select a cohort from existing cohorts
|
||||
Then I set its name as empty string and assignment type set to "manual"
|
||||
And I click on Save button
|
||||
Then I should see an error message
|
||||
"""
|
||||
cohort_name = str(uuid.uuid4().hex[0:20])
|
||||
new_cohort_name = ''
|
||||
self._verify_cohort_settings(
|
||||
cohort_name=cohort_name,
|
||||
assignment_type='random',
|
||||
new_cohort_name=new_cohort_name,
|
||||
new_assignment_type='manual',
|
||||
verify_updated=True
|
||||
)
|
||||
|
||||
def test_default_cohort_assignment_settings(self):
|
||||
"""
|
||||
Scenario: Cohort assignment settings are disabled for default cohort.
|
||||
|
||||
Given I have a course with a user in the course
|
||||
And I have added a manual cohort
|
||||
And I have added a random cohort
|
||||
When I select the random cohort
|
||||
Then cohort assignment settings are disabled
|
||||
"""
|
||||
self.cohort_management_page.select_cohort("AutoCohort1")
|
||||
self.cohort_management_page.select_cohort_settings()
|
||||
|
||||
self.assertTrue(self.cohort_management_page.is_assignment_settings_disabled)
|
||||
|
||||
message = "There must be one cohort to which students can automatically be assigned."
|
||||
self.assertEqual(message, self.cohort_management_page.assignment_settings_message)
|
||||
|
||||
def test_cohort_enable_disable(self):
|
||||
"""
|
||||
Scenario: Cohort Enable/Disable checkbox related functionality is working as intended.
|
||||
|
||||
Given I have a cohorted course with a user.
|
||||
And I can see the `Enable Cohorts` checkbox is checked.
|
||||
And cohort management controls are visible.
|
||||
When I uncheck the `Enable Cohorts` checkbox.
|
||||
Then cohort management controls are not visible.
|
||||
And When I reload the page.
|
||||
Then I can see the `Enable Cohorts` checkbox is unchecked.
|
||||
And cohort management controls are not visible.
|
||||
"""
|
||||
self.assertTrue(self.cohort_management_page.is_cohorted)
|
||||
self.assertTrue(self.cohort_management_page.cohort_management_controls_visible())
|
||||
self.cohort_management_page.is_cohorted = False
|
||||
self.assertFalse(self.cohort_management_page.cohort_management_controls_visible())
|
||||
self.browser.refresh()
|
||||
self.cohort_management_page.wait_for_page()
|
||||
self.assertFalse(self.cohort_management_page.is_cohorted)
|
||||
self.assertFalse(self.cohort_management_page.cohort_management_controls_visible())
|
||||
|
||||
def test_link_to_data_download(self):
|
||||
"""
|
||||
Scenario: a link is present from the cohort configuration in
|
||||
the instructor dashboard to the Data Download section.
|
||||
|
||||
Given I have a course with a cohort defined
|
||||
When I view the cohort in the LMS instructor dashboard
|
||||
There is a link to take me to the Data Download section of the Instructor Dashboard.
|
||||
"""
|
||||
self.cohort_management_page.select_data_download()
|
||||
data_download_page = DataDownloadPage(self.browser)
|
||||
data_download_page.wait_for_page()
|
||||
|
||||
def test_cohort_by_csv_both_columns(self):
|
||||
"""
|
||||
Scenario: the instructor can upload a file with user and cohort assignments, using both emails and usernames.
|
||||
|
||||
Given I have a course with two cohorts defined
|
||||
When I go to the cohort management section of the instructor dashboard
|
||||
I can upload a CSV file with assignments of users to cohorts via both usernames and emails
|
||||
Then I can download a file with results
|
||||
And appropriate events have been emitted
|
||||
"""
|
||||
csv_contents = [
|
||||
['username', 'email', 'ignored_column', 'cohort'],
|
||||
[self.instructor_name, '', 'June', 'ManualCohort1'],
|
||||
['', self.student_email, 'Spring', 'AutoCohort1'],
|
||||
[self.other_student_name, '', 'Fall', 'ManualCohort1'],
|
||||
]
|
||||
filename = "cohort_csv_both_columns_1.csv"
|
||||
self._create_csv_file(filename, csv_contents)
|
||||
self._verify_csv_upload_acceptable_file(filename)
|
||||
|
||||
def test_cohort_by_csv_only_email(self):
|
||||
"""
|
||||
Scenario: the instructor can upload a file with user and cohort assignments, using only emails.
|
||||
|
||||
Given I have a course with two cohorts defined
|
||||
When I go to the cohort management section of the instructor dashboard
|
||||
I can upload a CSV file with assignments of users to cohorts via only emails
|
||||
Then I can download a file with results
|
||||
And appropriate events have been emitted
|
||||
"""
|
||||
csv_contents = [
|
||||
['email', 'cohort'],
|
||||
[self.instructor_email, 'ManualCohort1'],
|
||||
[self.student_email, 'AutoCohort1'],
|
||||
[self.other_student_email, 'ManualCohort1'],
|
||||
]
|
||||
filename = "cohort_csv_emails_only.csv"
|
||||
self._create_csv_file(filename, csv_contents)
|
||||
self._verify_csv_upload_acceptable_file(filename)
|
||||
|
||||
def test_cohort_by_csv_only_username(self):
|
||||
"""
|
||||
Scenario: the instructor can upload a file with user and cohort assignments, using only usernames.
|
||||
|
||||
Given I have a course with two cohorts defined
|
||||
When I go to the cohort management section of the instructor dashboard
|
||||
I can upload a CSV file with assignments of users to cohorts via only usernames
|
||||
Then I can download a file with results
|
||||
And appropriate events have been emitted
|
||||
"""
|
||||
csv_contents = [
|
||||
['username', 'cohort'],
|
||||
[self.instructor_name, 'ManualCohort1'],
|
||||
[self.student_name, 'AutoCohort1'],
|
||||
[self.other_student_name, 'ManualCohort1'],
|
||||
]
|
||||
filename = "cohort_users_only_username1.csv"
|
||||
self._create_csv_file(filename, csv_contents)
|
||||
self._verify_csv_upload_acceptable_file(filename)
|
||||
|
||||
# TODO: Change unicode_hello_in_korean = u'ßßßßßß' to u'안녕하세요', after up gradation of Chrome driver. See TNL-3944
|
||||
def test_cohort_by_csv_unicode(self):
|
||||
"""
|
||||
Scenario: the instructor can upload a file with user and cohort assignments, using both emails and usernames.
|
||||
|
||||
Given I have a course with two cohorts defined
|
||||
And I add another cohort with a unicode name
|
||||
When I go to the cohort management section of the instructor dashboard
|
||||
I can upload a CSV file with assignments of users to the unicode cohort via both usernames and emails
|
||||
Then I can download a file with results
|
||||
|
||||
TODO: refactor events verification to handle this scenario. Events verification assumes movements
|
||||
between other cohorts (manual and auto).
|
||||
"""
|
||||
unicode_hello_in_korean = u'ßßßßßß'
|
||||
self._verify_cohort_settings(cohort_name=unicode_hello_in_korean, assignment_type=None)
|
||||
csv_contents = [
|
||||
['username', 'email', 'cohort'],
|
||||
[self.instructor_name, '', unicode_hello_in_korean],
|
||||
['', self.student_email, unicode_hello_in_korean],
|
||||
[self.other_student_name, '', unicode_hello_in_korean]
|
||||
]
|
||||
filename = "cohort_unicode_name.csv"
|
||||
self._create_csv_file(filename, csv_contents)
|
||||
self._verify_csv_upload_acceptable_file(filename, skip_events=True)
|
||||
|
||||
def _verify_csv_upload_acceptable_file(self, filename, skip_events=None):
|
||||
"""
|
||||
Helper method to verify cohort assignments after a successful CSV upload.
|
||||
|
||||
When skip_events is specified, no assertions are made on events.
|
||||
"""
|
||||
start_time = datetime.now(UTC)
|
||||
self.cohort_management_page.upload_cohort_file(filename)
|
||||
self._verify_cohort_by_csv_notification(
|
||||
u"Your file '{}' has been uploaded. Allow a few minutes for processing.".format(filename)
|
||||
)
|
||||
|
||||
if not skip_events:
|
||||
# student_user is moved from manual cohort to auto cohort
|
||||
self.assertEqual(
|
||||
self.event_collection.find({
|
||||
"name": "edx.cohort.user_added",
|
||||
"time": {"$gt": start_time},
|
||||
"event.user_id": {"$in": [int(self.student_id)]},
|
||||
"event.cohort_name": self.auto_cohort_name,
|
||||
}).count(),
|
||||
1
|
||||
)
|
||||
self.assertEqual(
|
||||
self.event_collection.find({
|
||||
"name": "edx.cohort.user_removed",
|
||||
"time": {"$gt": start_time},
|
||||
"event.user_id": int(self.student_id),
|
||||
"event.cohort_name": self.manual_cohort_name,
|
||||
}).count(),
|
||||
1
|
||||
)
|
||||
# instructor_user (previously unassigned) is added to manual cohort
|
||||
self.assertEqual(
|
||||
self.event_collection.find({
|
||||
"name": "edx.cohort.user_added",
|
||||
"time": {"$gt": start_time},
|
||||
"event.user_id": {"$in": [int(self.instructor_id)]},
|
||||
"event.cohort_name": self.manual_cohort_name,
|
||||
}).count(),
|
||||
1
|
||||
)
|
||||
# other_student_user (previously unassigned) is added to manual cohort
|
||||
self.assertEqual(
|
||||
self.event_collection.find({
|
||||
"name": "edx.cohort.user_added",
|
||||
"time": {"$gt": start_time},
|
||||
"event.user_id": {"$in": [int(self.other_student_id)]},
|
||||
"event.cohort_name": self.manual_cohort_name,
|
||||
}).count(),
|
||||
1
|
||||
)
|
||||
|
||||
# Verify the results can be downloaded.
|
||||
data_download = self.instructor_dashboard_page.select_data_download()
|
||||
data_download.wait_for_available_report()
|
||||
report = data_download.get_available_reports_for_download()[0]
|
||||
base_file_name = "cohort_results_"
|
||||
self.assertIn("{}_{}".format(
|
||||
'_'.join([self.course_info['org'], self.course_info['number'], self.course_info['run']]), base_file_name
|
||||
), report)
|
||||
report_datetime = datetime.strptime(
|
||||
report[report.index(base_file_name) + len(base_file_name):-len(".csv")],
|
||||
"%Y-%m-%d-%H%M"
|
||||
)
|
||||
self.assertLessEqual(start_time.replace(second=0, microsecond=0), utc.localize(report_datetime))
|
||||
|
||||
def test_cohort_by_csv_wrong_file_type(self):
|
||||
"""
|
||||
Scenario: if the instructor uploads a non-csv file, an error message is presented.
|
||||
|
||||
Given I have a course with cohorting enabled
|
||||
When I go to the cohort management section of the instructor dashboard
|
||||
And I upload a file without the CSV extension
|
||||
Then I get an error message stating that the file must have a CSV extension
|
||||
"""
|
||||
self.cohort_management_page.upload_cohort_file("image.jpg")
|
||||
self._verify_cohort_by_csv_notification("The file must end with the extension '.csv'.")
|
||||
|
||||
def test_cohort_by_csv_missing_cohort(self):
|
||||
"""
|
||||
Scenario: if the instructor uploads a csv file with no cohort column, an error message is presented.
|
||||
|
||||
Given I have a course with cohorting enabled
|
||||
When I go to the cohort management section of the instructor dashboard
|
||||
And I upload a CSV file that is missing the cohort column
|
||||
Then I get an error message stating that the file must have a cohort column
|
||||
"""
|
||||
self.cohort_management_page.upload_cohort_file("cohort_users_missing_cohort_column.csv")
|
||||
self._verify_cohort_by_csv_notification("The file must contain a 'cohort' column containing cohort names.")
|
||||
|
||||
def test_cohort_by_csv_missing_user(self):
|
||||
"""
|
||||
Scenario: if the instructor uploads a csv file with no username or email column, an error message is presented.
|
||||
|
||||
Given I have a course with cohorting enabled
|
||||
When I go to the cohort management section of the instructor dashboard
|
||||
And I upload a CSV file that is missing both the username and email columns
|
||||
Then I get an error message stating that the file must have either a username or email column
|
||||
"""
|
||||
self.cohort_management_page.upload_cohort_file("cohort_users_missing_user_columns.csv")
|
||||
self._verify_cohort_by_csv_notification(
|
||||
"The file must contain a 'username' column, an 'email' column, or both."
|
||||
)
|
||||
|
||||
def _verify_cohort_by_csv_notification(self, expected_message):
|
||||
"""
|
||||
Helper method to check the CSV file upload notification message.
|
||||
"""
|
||||
# Wait for notification message to appear, indicating file has been uploaded.
|
||||
EmptyPromise(
|
||||
lambda: 1 == len(self.cohort_management_page.get_csv_messages()), 'Waiting for notification'
|
||||
).fulfill()
|
||||
messages = self.cohort_management_page.get_csv_messages()
|
||||
self.assertEqual(expected_message, messages[0])
|
||||
|
||||
@attr('a11y')
|
||||
def test_cohorts_management_a11y(self):
|
||||
"""
|
||||
@@ -685,192 +80,3 @@ class CohortConfigurationTest(EventsTestMixin, UniqueCourseTest, CohortTestMixin
|
||||
]
|
||||
})
|
||||
self.cohort_management_page.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
|
||||
@attr(shard=15)
|
||||
class CohortContentGroupAssociationTest(UniqueCourseTest, CohortTestMixin):
|
||||
"""
|
||||
Tests for linking between content groups and cohort in the instructor dashboard.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up a cohorted course with a user_partition of scheme "cohort".
|
||||
"""
|
||||
super(CohortContentGroupAssociationTest, self).setUp()
|
||||
|
||||
# create course with single cohort and two content groups (user_partition of type "cohort")
|
||||
self.cohort_name = "OnlyCohort"
|
||||
self.course_fixture = CourseFixture(**self.course_info).install()
|
||||
self.setup_cohort_config(self.course_fixture)
|
||||
self.cohort_id = self.add_manual_cohort(self.course_fixture, self.cohort_name)
|
||||
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
create_user_partition_json(
|
||||
0,
|
||||
'Apples, Bananas',
|
||||
'Content Group Partition',
|
||||
[Group("0", 'Apples'), Group("1", 'Bananas')],
|
||||
scheme="cohort"
|
||||
)
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
# login as an instructor
|
||||
self.instructor_name = "instructor_user"
|
||||
self.instructor_id = AutoAuthPage(
|
||||
self.browser, username=self.instructor_name, email="instructor_user@example.com",
|
||||
course_id=self.course_id, staff=True
|
||||
).visit().get_user_id()
|
||||
|
||||
# go to the membership page on the instructor dashboard
|
||||
self.instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id)
|
||||
self.instructor_dashboard_page.visit()
|
||||
self.cohort_management_page = self.instructor_dashboard_page.select_cohort_management()
|
||||
|
||||
def test_no_content_group_linked(self):
|
||||
"""
|
||||
Scenario: In a course with content groups, cohorts are initially not linked to a content group
|
||||
|
||||
Given I have a course with a cohort defined and content groups defined
|
||||
When I view the cohort in the instructor dashboard and select settings
|
||||
Then the cohort is not linked to a content group
|
||||
And there is no text stating that content groups are undefined
|
||||
And the content groups are listed in the selector
|
||||
"""
|
||||
self.cohort_management_page.select_cohort(self.cohort_name)
|
||||
self.assertIsNone(self.cohort_management_page.get_cohort_associated_content_group())
|
||||
self.assertIsNone(self.cohort_management_page.get_cohort_related_content_group_message())
|
||||
self.assertEqual(["Apples", "Bananas"], self.cohort_management_page.get_all_content_groups())
|
||||
|
||||
def test_link_to_content_group(self):
|
||||
"""
|
||||
Scenario: In a course with content groups, cohorts can be linked to content groups
|
||||
|
||||
Given I have a course with a cohort defined and content groups defined
|
||||
When I view the cohort in the instructor dashboard and select settings
|
||||
And I link the cohort to one of the content groups and save
|
||||
Then there is a notification that my cohort has been saved
|
||||
And when I reload the page
|
||||
And I view the cohort in the instructor dashboard and select settings
|
||||
Then the cohort is still linked to the content group
|
||||
"""
|
||||
self._link_cohort_to_content_group(self.cohort_name, "Bananas")
|
||||
self.assertEqual("Bananas", self.cohort_management_page.get_cohort_associated_content_group())
|
||||
|
||||
def test_unlink_from_content_group(self):
|
||||
"""
|
||||
Scenario: In a course with content groups, cohorts can be unlinked from content groups
|
||||
|
||||
Given I have a course with a cohort defined and content groups defined
|
||||
When I view the cohort in the instructor dashboard and select settings
|
||||
And I link the cohort to one of the content groups and save
|
||||
Then there is a notification that my cohort has been saved
|
||||
And I reload the page
|
||||
And I view the cohort in the instructor dashboard and select settings
|
||||
And I unlink the cohort from any content group and save
|
||||
Then there is a notification that my cohort has been saved
|
||||
And when I reload the page
|
||||
And I view the cohort in the instructor dashboard and select settings
|
||||
Then the cohort is not linked to any content group
|
||||
"""
|
||||
self._link_cohort_to_content_group(self.cohort_name, "Bananas")
|
||||
self.cohort_management_page.set_cohort_associated_content_group(None)
|
||||
self._verify_settings_saved_and_reload(self.cohort_name)
|
||||
self.assertEqual(None, self.cohort_management_page.get_cohort_associated_content_group())
|
||||
|
||||
def test_create_new_cohort_linked_to_content_group(self):
|
||||
"""
|
||||
Scenario: In a course with content groups, a new cohort can be linked to a content group
|
||||
at time of creation.
|
||||
|
||||
Given I have a course with a cohort defined and content groups defined
|
||||
When I create a new cohort and link it to a content group
|
||||
Then when I select settings I see that the cohort is linked to the content group
|
||||
And when I reload the page
|
||||
And I view the cohort in the instructor dashboard and select settings
|
||||
Then the cohort is still linked to the content group
|
||||
"""
|
||||
new_cohort = "correctly linked cohort"
|
||||
self._create_new_cohort_linked_to_content_group(new_cohort, "Apples")
|
||||
self.browser.refresh()
|
||||
self.cohort_management_page.wait_for_page()
|
||||
self.cohort_management_page.select_cohort(new_cohort)
|
||||
self.assertEqual("Apples", self.cohort_management_page.get_cohort_associated_content_group())
|
||||
|
||||
def test_missing_content_group(self):
|
||||
"""
|
||||
Scenario: In a course with content groups, if a cohort is associated with a content group that no longer
|
||||
exists, a warning message is shown
|
||||
|
||||
Given I have a course with a cohort defined and content groups defined
|
||||
When I create a new cohort and link it to a content group
|
||||
And I delete that content group from the course
|
||||
And I reload the page
|
||||
And I view the cohort in the instructor dashboard and select settings
|
||||
Then the settings display a message that the content group no longer exists
|
||||
And when I select a different content group and save
|
||||
Then the error message goes away
|
||||
"""
|
||||
new_cohort = "linked to missing content group"
|
||||
self._create_new_cohort_linked_to_content_group(new_cohort, "Apples")
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
create_user_partition_json(
|
||||
0,
|
||||
'Apples, Bananas',
|
||||
'Content Group Partition',
|
||||
[Group("2", 'Pears'), Group("1", 'Bananas')],
|
||||
scheme="cohort"
|
||||
)
|
||||
],
|
||||
},
|
||||
})
|
||||
self.browser.refresh()
|
||||
self.cohort_management_page.wait_for_page()
|
||||
self.cohort_management_page.select_cohort(new_cohort)
|
||||
self.assertEqual("Deleted Content Group", self.cohort_management_page.get_cohort_associated_content_group())
|
||||
self.assertEqual(
|
||||
["Bananas", "Pears", "Deleted Content Group"],
|
||||
self.cohort_management_page.get_all_content_groups()
|
||||
)
|
||||
self.assertEqual(
|
||||
"Warning:\nThe previously selected content group was deleted. Select another content group.",
|
||||
self.cohort_management_page.get_cohort_related_content_group_message()
|
||||
)
|
||||
self.cohort_management_page.set_cohort_associated_content_group("Pears")
|
||||
confirmation_messages = self.cohort_management_page.get_cohort_settings_messages()
|
||||
self.assertEqual(["Saved cohort"], confirmation_messages)
|
||||
self.assertIsNone(self.cohort_management_page.get_cohort_related_content_group_message())
|
||||
self.assertEqual(["Bananas", "Pears"], self.cohort_management_page.get_all_content_groups())
|
||||
|
||||
def _create_new_cohort_linked_to_content_group(self, new_cohort, cohort_group):
|
||||
"""
|
||||
Creates a new cohort linked to a content group.
|
||||
"""
|
||||
self.cohort_management_page.add_cohort(new_cohort, content_group=cohort_group)
|
||||
self.assertEqual(cohort_group, self.cohort_management_page.get_cohort_associated_content_group())
|
||||
|
||||
def _link_cohort_to_content_group(self, cohort_name, content_group):
|
||||
"""
|
||||
Links a cohort to a content group. Saves the changes and verifies the cohort updated properly.
|
||||
Then refreshes the page and selects the cohort.
|
||||
"""
|
||||
self.cohort_management_page.select_cohort(cohort_name)
|
||||
self.cohort_management_page.set_cohort_associated_content_group(content_group)
|
||||
self._verify_settings_saved_and_reload(cohort_name)
|
||||
|
||||
def _verify_settings_saved_and_reload(self, cohort_name):
|
||||
"""
|
||||
Verifies the confirmation message indicating that a cohort's settings have been updated.
|
||||
Then refreshes the page and selects the cohort.
|
||||
"""
|
||||
confirmation_messages = self.cohort_management_page.get_cohort_settings_messages()
|
||||
self.assertEqual(["Saved cohort"], confirmation_messages)
|
||||
self.browser.refresh()
|
||||
self.cohort_management_page.wait_for_page()
|
||||
self.cohort_management_page.select_cohort(cohort_name)
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"""
|
||||
Tests related to the cohorting feature.
|
||||
"""
|
||||
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.pages.lms.discussion import DiscussionTabSingleThreadPage, InlineDiscussionPage
|
||||
from common.test.acceptance.tests.discussion.helpers import BaseDiscussionMixin, BaseDiscussionTestCase, CohortTestMixin
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest
|
||||
from openedx.core.lib.tests import attr
|
||||
|
||||
|
||||
class NonCohortedDiscussionTestMixin(BaseDiscussionMixin):
|
||||
"""
|
||||
Mixin for tests of discussion in non-cohorted courses.
|
||||
"""
|
||||
def setup_cohorts(self):
|
||||
"""
|
||||
No cohorts are desired for this mixin.
|
||||
"""
|
||||
pass
|
||||
|
||||
def test_non_cohort_visibility_label(self):
|
||||
self.setup_thread(1)
|
||||
self.assertEqual(self.thread_page.get_group_visibility_label(), "This post is visible to everyone.")
|
||||
|
||||
|
||||
class CohortedDiscussionTestMixin(BaseDiscussionMixin, CohortTestMixin):
|
||||
"""
|
||||
Mixin for tests of discussion in cohorted courses.
|
||||
"""
|
||||
def setup_cohorts(self):
|
||||
"""
|
||||
Sets up the course to use cohorting with a single defined cohort.
|
||||
"""
|
||||
self.setup_cohort_config(self.course_fixture)
|
||||
self.cohort_1_name = "Cohort 1"
|
||||
self.cohort_1_id = self.add_manual_cohort(self.course_fixture, self.cohort_1_name)
|
||||
|
||||
def test_cohort_visibility_label(self):
|
||||
# Must be moderator to view content in a cohort other than your own
|
||||
AutoAuthPage(self.browser, course_id=self.course_id, roles="Moderator").visit()
|
||||
self.thread_id = self.setup_thread(1, group_id=self.cohort_1_id)
|
||||
|
||||
# Enable cohorts and verify that the post shows to cohort only.
|
||||
self.enable_cohorting(self.course_fixture)
|
||||
self.enable_always_divide_inline_discussions(self.course_fixture)
|
||||
self.refresh_thread_page(self.thread_id)
|
||||
self.assertEqual(
|
||||
self.thread_page.get_group_visibility_label(),
|
||||
u"This post is visible only to {}.".format(self.cohort_1_name)
|
||||
)
|
||||
|
||||
# Disable cohorts and verify that the post now shows as visible to everyone.
|
||||
self.disable_cohorting(self.course_fixture)
|
||||
self.refresh_thread_page(self.thread_id)
|
||||
self.assertEqual(self.thread_page.get_group_visibility_label(), "This post is visible to everyone.")
|
||||
|
||||
|
||||
class DiscussionTabSingleThreadTest(BaseDiscussionTestCase):
|
||||
"""
|
||||
Tests for the discussion page displaying a single thread.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(DiscussionTabSingleThreadTest, self).setUp()
|
||||
self.setup_cohorts()
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
def setup_thread_page(self, thread_id):
|
||||
self.thread_page = DiscussionTabSingleThreadPage(self.browser, self.course_id, self.discussion_id, thread_id) # pylint: disable=attribute-defined-outside-init
|
||||
self.thread_page.visit()
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def refresh_thread_page(self, thread_id):
|
||||
self.browser.refresh()
|
||||
self.thread_page.wait_for_page()
|
||||
|
||||
|
||||
@attr(shard=5)
|
||||
class CohortedDiscussionTabSingleThreadTest(DiscussionTabSingleThreadTest, CohortedDiscussionTestMixin):
|
||||
"""
|
||||
Tests for the discussion page displaying a single cohorted thread.
|
||||
"""
|
||||
# Actual test method(s) defined in CohortedDiscussionTestMixin.
|
||||
pass
|
||||
|
||||
|
||||
@attr(shard=5)
|
||||
class NonCohortedDiscussionTabSingleThreadTest(DiscussionTabSingleThreadTest, NonCohortedDiscussionTestMixin):
|
||||
"""
|
||||
Tests for the discussion page displaying a single non-cohorted thread.
|
||||
"""
|
||||
# Actual test method(s) defined in NonCohortedDiscussionTestMixin.
|
||||
pass
|
||||
|
||||
|
||||
class InlineDiscussionTest(UniqueCourseTest):
|
||||
"""
|
||||
Tests for inline discussions
|
||||
"""
|
||||
def setUp(self):
|
||||
super(InlineDiscussionTest, self).setUp()
|
||||
self.discussion_id = "test_discussion_{}".format(uuid4().hex)
|
||||
self.course_fixture = CourseFixture(**self.course_info).add_children(
|
||||
XBlockFixtureDesc("chapter", "Test Section").add_children(
|
||||
XBlockFixtureDesc("sequential", "Test Subsection").add_children(
|
||||
XBlockFixtureDesc("vertical", "Test Unit").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"discussion",
|
||||
"Test Discussion",
|
||||
metadata={"discussion_id": self.discussion_id}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
self.setup_cohorts()
|
||||
|
||||
self.user_id = AutoAuthPage(self.browser, course_id=self.course_id).visit().get_user_id()
|
||||
|
||||
def setup_thread_page(self, thread_id):
|
||||
CoursewarePage(self.browser, self.course_id).visit()
|
||||
self.show_thread(thread_id)
|
||||
|
||||
def show_thread(self, thread_id):
|
||||
discussion_page = InlineDiscussionPage(self.browser, self.discussion_id)
|
||||
if not discussion_page.is_discussion_expanded():
|
||||
discussion_page.expand_discussion()
|
||||
self.assertEqual(discussion_page.get_num_displayed_threads(), 1)
|
||||
discussion_page.show_thread(thread_id)
|
||||
self.thread_page = discussion_page.thread_page # pylint: disable=attribute-defined-outside-init
|
||||
|
||||
def refresh_thread_page(self, thread_id):
|
||||
self.browser.refresh()
|
||||
self.show_thread(thread_id)
|
||||
|
||||
|
||||
@attr(shard=5)
|
||||
class CohortedInlineDiscussionTest(InlineDiscussionTest, CohortedDiscussionTestMixin):
|
||||
"""
|
||||
Tests for cohorted inline discussions.
|
||||
"""
|
||||
# Actual test method(s) defined in CohortedDiscussionTestMixin.
|
||||
pass
|
||||
|
||||
|
||||
@attr(shard=5)
|
||||
class NonCohortedInlineDiscussionTest(InlineDiscussionTest, NonCohortedDiscussionTestMixin):
|
||||
"""
|
||||
Tests for non-cohorted inline discussions.
|
||||
"""
|
||||
# Actual test method(s) defined in NonCohortedDiscussionTestMixin.
|
||||
pass
|
||||
@@ -3,39 +3,24 @@ Tests for discussion pages
|
||||
"""
|
||||
|
||||
|
||||
import datetime
|
||||
import time
|
||||
from unittest import skip
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pytz import UTC
|
||||
import six
|
||||
from six.moves import map
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.fixtures.discussion import (
|
||||
Comment,
|
||||
Response,
|
||||
SearchResult,
|
||||
SearchResultFixture,
|
||||
SingleThreadViewFixture,
|
||||
Thread,
|
||||
UserProfileViewFixture
|
||||
)
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.pages.lms.discussion import (
|
||||
DiscussionSortPreferencePage,
|
||||
DiscussionTabHomePage,
|
||||
DiscussionTabSingleThreadPage,
|
||||
DiscussionUserProfilePage,
|
||||
InlineDiscussionPage
|
||||
)
|
||||
from common.test.acceptance.pages.lms.learner_profile import LearnerProfilePage
|
||||
from common.test.acceptance.pages.lms.tab_nav import TabNavPage
|
||||
from common.test.acceptance.tests.discussion.helpers import BaseDiscussionMixin, BaseDiscussionTestCase
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest, get_modal_alert, skip_if_browser
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest
|
||||
from openedx.core.lib.tests import attr
|
||||
|
||||
THREAD_CONTENT_WITH_LATEX = u"""Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
|
||||
@@ -100,85 +85,6 @@ THREAD_CONTENT_WITH_LATEX = u"""Lorem ipsum dolor sit amet, consectetur adipisci
|
||||
"""
|
||||
|
||||
|
||||
class DiscussionResponsePaginationTestMixin(BaseDiscussionMixin):
|
||||
"""
|
||||
A mixin containing tests for response pagination for use by both inline
|
||||
discussion and the discussion tab
|
||||
"""
|
||||
def assert_response_display_correct(self, response_total, displayed_responses):
|
||||
"""
|
||||
Assert that various aspects of the display of responses are all correct:
|
||||
* Text indicating total number of responses
|
||||
* Presence of "Add a response" button
|
||||
* Number of responses actually displayed
|
||||
* Presence and text of indicator of how many responses are shown
|
||||
* Presence and text of button to load more responses
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.thread_page.get_response_total_text(),
|
||||
str(response_total) + " responses"
|
||||
)
|
||||
self.assertEqual(self.thread_page.has_add_response_button(), response_total != 0)
|
||||
self.assertEqual(self.thread_page.get_num_displayed_responses(), displayed_responses)
|
||||
self.assertEqual(
|
||||
self.thread_page.get_shown_responses_text(),
|
||||
(
|
||||
None if response_total == 0 else
|
||||
"Showing all responses" if response_total == displayed_responses else
|
||||
u"Showing first {} responses".format(displayed_responses)
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.thread_page.get_load_responses_button_text(),
|
||||
(
|
||||
None if response_total == displayed_responses else
|
||||
"Load all responses" if response_total - displayed_responses < 100 else
|
||||
"Load next 100 responses"
|
||||
)
|
||||
)
|
||||
|
||||
def test_pagination_no_responses(self):
|
||||
self.setup_thread(0)
|
||||
self.assert_response_display_correct(0, 0)
|
||||
|
||||
def test_pagination_few_responses(self):
|
||||
self.setup_thread(5)
|
||||
self.assert_response_display_correct(5, 5)
|
||||
|
||||
def test_pagination_two_response_pages(self):
|
||||
self.setup_thread(50)
|
||||
self.assert_response_display_correct(50, 25)
|
||||
|
||||
self.thread_page.load_more_responses()
|
||||
self.assert_response_display_correct(50, 50)
|
||||
|
||||
def test_pagination_exactly_two_response_pages(self):
|
||||
self.setup_thread(125)
|
||||
self.assert_response_display_correct(125, 25)
|
||||
|
||||
self.thread_page.load_more_responses()
|
||||
self.assert_response_display_correct(125, 125)
|
||||
|
||||
def test_pagination_three_response_pages(self):
|
||||
self.setup_thread(150)
|
||||
self.assert_response_display_correct(150, 25)
|
||||
|
||||
self.thread_page.load_more_responses()
|
||||
self.assert_response_display_correct(150, 125)
|
||||
|
||||
self.thread_page.load_more_responses()
|
||||
self.assert_response_display_correct(150, 150)
|
||||
|
||||
def test_add_response_button(self):
|
||||
self.setup_thread(5)
|
||||
self.assertTrue(self.thread_page.has_add_response_button())
|
||||
self.thread_page.click_add_response_button()
|
||||
|
||||
def test_add_response_button_closed_thread(self):
|
||||
self.setup_thread(5, closed=True)
|
||||
self.assertFalse(self.thread_page.has_add_response_button())
|
||||
|
||||
|
||||
@attr(shard=2)
|
||||
class DiscussionHomePageTest(BaseDiscussionTestCase):
|
||||
"""
|
||||
@@ -193,37 +99,6 @@ class DiscussionHomePageTest(BaseDiscussionTestCase):
|
||||
self.page = DiscussionTabHomePage(self.browser, self.course_id)
|
||||
self.page.visit()
|
||||
|
||||
@attr(shard=2)
|
||||
def test_new_post_button(self):
|
||||
"""
|
||||
Scenario: I can create new posts from the Discussion home page.
|
||||
Given that I am on the Discussion home page
|
||||
When I click on the 'New Post' button
|
||||
Then I should be shown the new post form
|
||||
"""
|
||||
self.assertIsNotNone(self.page.new_post_button)
|
||||
self.page.click_new_post_button()
|
||||
self.assertIsNotNone(self.page.new_post_form)
|
||||
|
||||
def test_receive_update_checkbox(self):
|
||||
"""
|
||||
Scenario: I can save the receive update email notification checkbox
|
||||
on Discussion home page.
|
||||
Given that I am on the Discussion home page
|
||||
When I click on the 'Receive update' checkbox
|
||||
Then it should always shown selected.
|
||||
"""
|
||||
receive_updates_selector = '.email-setting'
|
||||
receive_updates_checkbox = self.page.is_element_visible(receive_updates_selector)
|
||||
self.assertTrue(receive_updates_checkbox)
|
||||
|
||||
self.assertFalse(self.page.is_checkbox_selected(receive_updates_selector))
|
||||
self.page.click_element(receive_updates_selector)
|
||||
|
||||
self.assertTrue(self.page.is_checkbox_selected(receive_updates_selector))
|
||||
self.page.refresh_and_wait_for_load()
|
||||
self.assertTrue(self.page.is_checkbox_selected(receive_updates_selector))
|
||||
|
||||
@attr('a11y')
|
||||
def test_page_accessibility(self):
|
||||
self.page.a11y_audit.config.set_rules({
|
||||
@@ -237,207 +112,6 @@ class DiscussionHomePageTest(BaseDiscussionTestCase):
|
||||
self.page.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
|
||||
@attr(shard=2)
|
||||
class DiscussionNavigationTest(BaseDiscussionTestCase):
|
||||
"""
|
||||
Tests for breadcrumbs navigation in the Discussions page nav bar
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(DiscussionNavigationTest, self).setUp()
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
thread_id = "test_thread_{}".format(uuid4().hex)
|
||||
thread_fixture = SingleThreadViewFixture(
|
||||
Thread(
|
||||
id=thread_id,
|
||||
body=THREAD_CONTENT_WITH_LATEX,
|
||||
commentable_id=self.discussion_id
|
||||
)
|
||||
)
|
||||
thread_fixture.push()
|
||||
self.thread_page = DiscussionTabSingleThreadPage(
|
||||
self.browser,
|
||||
self.course_id,
|
||||
self.discussion_id,
|
||||
thread_id
|
||||
)
|
||||
self.thread_page.visit()
|
||||
|
||||
@skip("andya: 10/19/17: re-enable once the failure on Jenkins is determined")
|
||||
def test_breadcrumbs_push_topic(self):
|
||||
topic_button = self.thread_page.q(
|
||||
css=".forum-nav-browse-menu-item[data-discussion-id='{}']".format(self.discussion_id)
|
||||
)
|
||||
self.assertTrue(topic_button.visible)
|
||||
|
||||
topic_button.click()
|
||||
|
||||
# Verify the thread's topic has been pushed to breadcrumbs
|
||||
breadcrumbs = self.thread_page.q(css=".breadcrumbs .nav-item")
|
||||
self.assertEqual(len(breadcrumbs), 3)
|
||||
self.assertEqual(breadcrumbs[2].text, "Topic-Level Student-Visible Label")
|
||||
|
||||
@skip("andya: 10/19/17: re-enable once the failure on Jenkins is determined")
|
||||
def test_breadcrumbs_back_to_all_topics(self):
|
||||
topic_button = self.thread_page.q(
|
||||
css=".forum-nav-browse-menu-item[data-discussion-id='{}']".format(self.discussion_id)
|
||||
)
|
||||
self.assertTrue(topic_button.visible)
|
||||
topic_button.click()
|
||||
|
||||
# Verify clicking the first breadcrumb takes you back to all topics
|
||||
self.thread_page.q(css=".breadcrumbs .nav-item")[0].click()
|
||||
self.assertEqual(len(self.thread_page.q(css=".breadcrumbs .nav-item")), 1)
|
||||
|
||||
def test_breadcrumbs_clear_search(self):
|
||||
self.thread_page.q(css=".search-input").fill("search text")
|
||||
self.thread_page.q(css=".search-button").click()
|
||||
|
||||
# Verify that clicking the first breadcrumb clears your search
|
||||
self.thread_page.q(css=".breadcrumbs .nav-item")[0].click()
|
||||
self.assertEqual(self.thread_page.q(css=".search-input").text[0], "")
|
||||
|
||||
@skip("andya: 10/19/17: re-enable once the failure on Jenkins is determined")
|
||||
def test_navigation_and_sorting(self):
|
||||
"""
|
||||
Test that after adding the post, user sorting preference is changing properly
|
||||
and recently added post is shown.
|
||||
"""
|
||||
topic_button = self.thread_page.q(
|
||||
css=".forum-nav-browse-menu-item[data-discussion-id='{}']".format(self.discussion_id)
|
||||
)
|
||||
self.assertTrue(topic_button.visible)
|
||||
topic_button.click()
|
||||
sort_page = DiscussionSortPreferencePage(self.browser, self.course_id)
|
||||
for sort_type in ["votes", "comments", "activity"]:
|
||||
sort_page.change_sort_preference(sort_type)
|
||||
# Verify that recently added post titled "dummy thread title" is shown in each sorting preference
|
||||
self.assertEqual(self.thread_page.q(css=".forum-nav-thread-title").text[0], 'dummy thread title')
|
||||
|
||||
|
||||
@attr(shard=19)
|
||||
class DiscussionTabSingleThreadTest(BaseDiscussionTestCase, DiscussionResponsePaginationTestMixin):
|
||||
"""
|
||||
Tests for the discussion page displaying a single thread
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(DiscussionTabSingleThreadTest, self).setUp()
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
self.tab_nav = TabNavPage(self.browser)
|
||||
|
||||
def setup_thread_page(self, thread_id):
|
||||
self.thread_page = self.create_single_thread_page(thread_id) # pylint: disable=attribute-defined-outside-init
|
||||
self.thread_page.visit()
|
||||
|
||||
@skip("andya: 10/19/17: re-enable once the failure on Jenkins is determined")
|
||||
def test_mathjax_rendering(self):
|
||||
thread_id = "test_thread_{}".format(uuid4().hex)
|
||||
|
||||
thread_fixture = SingleThreadViewFixture(
|
||||
Thread(
|
||||
id=thread_id,
|
||||
body=THREAD_CONTENT_WITH_LATEX,
|
||||
commentable_id=self.discussion_id,
|
||||
thread_type="discussion"
|
||||
)
|
||||
)
|
||||
thread_fixture.push()
|
||||
self.setup_thread_page(thread_id)
|
||||
self.assertTrue(self.thread_page.is_discussion_body_visible())
|
||||
self.thread_page.verify_mathjax_preview_available()
|
||||
self.thread_page.verify_mathjax_rendered()
|
||||
|
||||
def test_markdown_reference_link(self):
|
||||
"""
|
||||
Check markdown editor renders reference link correctly
|
||||
and colon(:) in reference link is not converted to %3a
|
||||
"""
|
||||
sample_link = "http://example.com/colon:test"
|
||||
thread_content = """[enter link description here][1]\n[1]: http://example.com/colon:test"""
|
||||
thread_id = "test_thread_{}".format(uuid4().hex)
|
||||
thread_fixture = SingleThreadViewFixture(
|
||||
Thread(
|
||||
id=thread_id,
|
||||
body=thread_content,
|
||||
commentable_id=self.discussion_id,
|
||||
thread_type="discussion"
|
||||
)
|
||||
)
|
||||
thread_fixture.push()
|
||||
self.setup_thread_page(thread_id)
|
||||
self.assertEqual(self.thread_page.get_link_href(), sample_link)
|
||||
|
||||
def test_marked_answer_comments(self):
|
||||
thread_id = "test_thread_{}".format(uuid4().hex)
|
||||
response_id = "test_response_{}".format(uuid4().hex)
|
||||
comment_id = "test_comment_{}".format(uuid4().hex)
|
||||
thread_fixture = SingleThreadViewFixture(
|
||||
Thread(id=thread_id, commentable_id=self.discussion_id, thread_type="question")
|
||||
)
|
||||
thread_fixture.addResponse(
|
||||
Response(id=response_id, endorsed=True),
|
||||
[Comment(id=comment_id)]
|
||||
)
|
||||
thread_fixture.push()
|
||||
self.setup_thread_page(thread_id)
|
||||
self.assertFalse(self.thread_page.is_comment_visible(comment_id))
|
||||
self.assertFalse(self.thread_page.is_add_comment_visible(response_id))
|
||||
self.assertTrue(self.thread_page.is_show_comments_visible(response_id))
|
||||
self.thread_page.show_comments(response_id)
|
||||
self.assertTrue(self.thread_page.is_comment_visible(comment_id))
|
||||
self.assertTrue(self.thread_page.is_add_comment_visible(response_id))
|
||||
self.assertFalse(self.thread_page.is_show_comments_visible(response_id))
|
||||
|
||||
def test_discussion_blackout_period(self):
|
||||
"""
|
||||
Verify that new discussion can not be started during course blackout period.
|
||||
|
||||
Blackout period is the period between which students cannot post new or contribute
|
||||
to existing discussions.
|
||||
"""
|
||||
now = datetime.datetime.now(UTC)
|
||||
# Update course advance settings with a valid blackout period.
|
||||
self.course_fixture.add_advanced_settings(
|
||||
{
|
||||
u"discussion_blackouts": {
|
||||
"value": [
|
||||
[
|
||||
(now - datetime.timedelta(days=14)).isoformat(),
|
||||
(now + datetime.timedelta(days=2)).isoformat()
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
self.course_fixture._add_advanced_settings() # pylint: disable=protected-access
|
||||
self.browser.refresh()
|
||||
thread = Thread(id=uuid4().hex, commentable_id=self.discussion_id)
|
||||
thread_fixture = SingleThreadViewFixture(thread)
|
||||
thread_fixture.addResponse(
|
||||
Response(id="response1"),
|
||||
[Comment(id="comment1")])
|
||||
thread_fixture.push()
|
||||
self.setup_thread_page(thread.get("id"))
|
||||
|
||||
# Verify that `Add a Post` is not visible on course tab nav.
|
||||
self.assertFalse(self.tab_nav.has_new_post_button_visible_on_tab())
|
||||
|
||||
# Verify that `Add a response` button is not visible.
|
||||
self.assertFalse(self.thread_page.has_add_response_button())
|
||||
|
||||
# Verify user can not add new responses or modify existing responses.
|
||||
self.assertFalse(self.thread_page.has_discussion_reply_editor())
|
||||
self.assertFalse(self.thread_page.is_response_editable("response1"))
|
||||
self.assertFalse(self.thread_page.is_response_deletable("response1"))
|
||||
|
||||
# Verify that user can not add new comment to a response or modify existing responses.
|
||||
self.assertFalse(self.thread_page.is_add_comment_visible("response1"))
|
||||
self.assertFalse(self.thread_page.is_comment_editable("comment1"))
|
||||
self.assertFalse(self.thread_page.is_comment_deletable("comment1"))
|
||||
|
||||
|
||||
class DiscussionTabMultipleThreadTest(BaseDiscussionTestCase, BaseDiscussionMixin):
|
||||
"""
|
||||
Tests for the discussion page with multiple threads
|
||||
@@ -520,22 +194,6 @@ class DiscussionOpenClosedThreadTest(BaseDiscussionTestCase):
|
||||
page.close_open_thread()
|
||||
return page
|
||||
|
||||
@attr(shard=2)
|
||||
def test_originally_open_thread_vote_display(self):
|
||||
page = self.setup_openclosed_thread_page()
|
||||
self.assertFalse(page.is_element_visible('.thread-main-wrapper .action-vote'))
|
||||
self.assertTrue(page.is_element_visible('.thread-main-wrapper .display-vote'))
|
||||
self.assertFalse(page.is_element_visible('.response_response1 .action-vote'))
|
||||
self.assertTrue(page.is_element_visible('.response_response1 .display-vote'))
|
||||
|
||||
@attr(shard=2)
|
||||
def test_originally_closed_thread_vote_display(self):
|
||||
page = self.setup_openclosed_thread_page(True)
|
||||
self.assertTrue(page.is_element_visible('.thread-main-wrapper .action-vote'))
|
||||
self.assertFalse(page.is_element_visible('.thread-main-wrapper .display-vote'))
|
||||
self.assertTrue(page.is_element_visible('.response_response1 .action-vote'))
|
||||
self.assertFalse(page.is_element_visible('.response_response1 .display-vote'))
|
||||
|
||||
@attr('a11y')
|
||||
def test_page_accessibility(self):
|
||||
page = self.setup_openclosed_thread_page()
|
||||
@@ -563,36 +221,6 @@ class DiscussionOpenClosedThreadTest(BaseDiscussionTestCase):
|
||||
page.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
|
||||
@attr(shard=2)
|
||||
class DiscussionCommentDeletionTest(BaseDiscussionTestCase):
|
||||
"""
|
||||
Tests for deleting comments displayed beneath responses in the single thread view.
|
||||
"""
|
||||
def setup_user(self, roles=[]):
|
||||
roles_str = ','.join(roles)
|
||||
self.user_id = AutoAuthPage(self.browser, course_id=self.course_id, roles=roles_str).visit().get_user_id()
|
||||
|
||||
def setup_view(self):
|
||||
view = SingleThreadViewFixture(Thread(id="comment_deletion_test_thread", commentable_id=self.discussion_id))
|
||||
view.addResponse(
|
||||
Response(id="response1"), [
|
||||
Comment(id="comment_other_author"),
|
||||
Comment(id="comment_self_author", user_id=self.user_id, thread_id="comment_deletion_test_thread")
|
||||
]
|
||||
)
|
||||
view.push()
|
||||
|
||||
def test_comment_deletion_as_moderator(self):
|
||||
self.setup_user(roles=['Moderator'])
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("comment_deletion_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_comment_deletable("comment_self_author"))
|
||||
self.assertTrue(page.is_comment_deletable("comment_other_author"))
|
||||
page.delete_comment("comment_self_author")
|
||||
page.delete_comment("comment_other_author")
|
||||
|
||||
|
||||
class DiscussionResponseEditTest(BaseDiscussionTestCase):
|
||||
"""
|
||||
Tests for editing responses displayed beneath thread in the single thread view.
|
||||
@@ -611,227 +239,6 @@ class DiscussionResponseEditTest(BaseDiscussionTestCase):
|
||||
)
|
||||
view.push()
|
||||
|
||||
def edit_response(self, page, response_id):
|
||||
self.assertTrue(page.is_response_editable(response_id))
|
||||
page.start_response_edit(response_id)
|
||||
new_response = "edited body"
|
||||
page.set_response_editor_value(response_id, new_response)
|
||||
page.submit_response_edit(response_id, new_response)
|
||||
|
||||
@attr(shard=2)
|
||||
def test_edit_response_add_link(self):
|
||||
"""
|
||||
Scenario: User submits valid input to the 'add link' form
|
||||
Given I am editing a response on a discussion page
|
||||
When I click the 'add link' icon in the editor toolbar
|
||||
And enter a valid url to the URL input field
|
||||
And enter a valid string in the Description input field
|
||||
And click the 'OK' button
|
||||
Then the edited response should contain the new link
|
||||
"""
|
||||
self.setup_user()
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("response_edit_test_thread")
|
||||
page.visit()
|
||||
|
||||
response_id = "response_self_author"
|
||||
url = "http://example.com"
|
||||
description = "example"
|
||||
|
||||
page.start_response_edit(response_id)
|
||||
page.set_response_editor_value(response_id, "")
|
||||
page.add_content_via_editor_button(
|
||||
"link", response_id, url, description)
|
||||
page.submit_response_edit(response_id, description)
|
||||
|
||||
expected_response_html = (
|
||||
u'<p><a href="{}">{}</a></p>'.format(url, description)
|
||||
)
|
||||
actual_response_html = page.q(
|
||||
css=u".response_{} .response-body".format(response_id)
|
||||
).html[0]
|
||||
self.assertEqual(expected_response_html, actual_response_html)
|
||||
|
||||
@attr(shard=2)
|
||||
def test_edit_response_add_image(self):
|
||||
"""
|
||||
Scenario: User submits valid input to the 'add image' form
|
||||
Given I am editing a response on a discussion page
|
||||
When I click the 'add image' icon in the editor toolbar
|
||||
And enter a valid url to the URL input field
|
||||
And enter a valid string in the Description input field
|
||||
And click the 'OK' button
|
||||
Then the edited response should contain the new image
|
||||
"""
|
||||
self.setup_user()
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("response_edit_test_thread")
|
||||
page.visit()
|
||||
|
||||
response_id = "response_self_author"
|
||||
url = "http://www.example.com/something.png"
|
||||
description = "image from example.com"
|
||||
|
||||
page.start_response_edit(response_id)
|
||||
page.set_response_editor_value(response_id, "")
|
||||
page.add_content_via_editor_button(
|
||||
"image", response_id, url, description)
|
||||
page.submit_response_edit(response_id, '')
|
||||
|
||||
expected_response_html = (
|
||||
u'<p><img src="{}" alt="{}" title=""></p>'.format(url, description)
|
||||
)
|
||||
actual_response_html = page.q(
|
||||
css=u".response_{} .response-body".format(response_id)
|
||||
).html[0]
|
||||
self.assertEqual(expected_response_html, actual_response_html)
|
||||
|
||||
@attr(shard=2)
|
||||
def test_edit_response_add_image_error_msg(self):
|
||||
"""
|
||||
Scenario: User submits invalid input to the 'add image' form
|
||||
Given I am editing a response on a discussion page
|
||||
When I click the 'add image' icon in the editor toolbar
|
||||
And enter an invalid url to the URL input field
|
||||
And enter an empty string in the Description input field
|
||||
And click the 'OK' button
|
||||
Then I should be shown 2 error messages
|
||||
"""
|
||||
self.setup_user()
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("response_edit_test_thread")
|
||||
page.visit()
|
||||
page.start_response_edit("response_self_author")
|
||||
page.add_content_via_editor_button(
|
||||
"image", "response_self_author", '', '')
|
||||
page.verify_link_editor_error_messages_shown()
|
||||
|
||||
@attr(shard=2)
|
||||
def test_edit_response_add_decorative_image(self):
|
||||
"""
|
||||
Scenario: User submits invalid input to the 'add image' form
|
||||
Given I am editing a response on a discussion page
|
||||
When I click the 'add image' icon in the editor toolbar
|
||||
And enter a valid url to the URL input field
|
||||
And enter an empty string in the Description input field
|
||||
And I check the 'image is decorative' checkbox
|
||||
And click the 'OK' button
|
||||
Then the edited response should contain the new image
|
||||
"""
|
||||
self.setup_user()
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("response_edit_test_thread")
|
||||
page.visit()
|
||||
|
||||
response_id = "response_self_author"
|
||||
url = "http://www.example.com/something.png"
|
||||
description = ""
|
||||
|
||||
page.start_response_edit(response_id)
|
||||
page.set_response_editor_value(response_id, "Some content")
|
||||
page.add_content_via_editor_button(
|
||||
"image", response_id, url, description, is_decorative=True)
|
||||
page.submit_response_edit(response_id, "Some content")
|
||||
|
||||
expected_response_html = (
|
||||
u'<p>Some content<img src="{}" alt="{}" title=""></p>'.format(
|
||||
url, description)
|
||||
)
|
||||
actual_response_html = page.q(
|
||||
css=u".response_{} .response-body".format(response_id)
|
||||
).html[0]
|
||||
self.assertEqual(expected_response_html, actual_response_html)
|
||||
|
||||
@attr(shard=2)
|
||||
def test_edit_response_add_link_error_msg(self):
|
||||
"""
|
||||
Scenario: User submits invalid input to the 'add link' form
|
||||
Given I am editing a response on a discussion page
|
||||
When I click the 'add link' icon in the editor toolbar
|
||||
And enter an invalid url to the URL input field
|
||||
And enter an empty string in the Description input field
|
||||
And click the 'OK' button
|
||||
Then I should be shown 2 error messages
|
||||
"""
|
||||
self.setup_user()
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("response_edit_test_thread")
|
||||
page.visit()
|
||||
page.start_response_edit("response_self_author")
|
||||
page.add_content_via_editor_button(
|
||||
"link", "response_self_author", '', '')
|
||||
page.verify_link_editor_error_messages_shown()
|
||||
|
||||
@attr(shard=2)
|
||||
def test_edit_response_as_student(self):
|
||||
"""
|
||||
Scenario: Students should be able to edit the response they created not responses of other users
|
||||
Given that I am on discussion page with student logged in
|
||||
When I try to edit the response created by student
|
||||
Then the response should be edited and rendered successfully
|
||||
And responses from other users should be shown over there
|
||||
And the student should be able to edit the response of other people
|
||||
"""
|
||||
self.setup_user()
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("response_edit_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_response_visible("response_other_author"))
|
||||
self.assertFalse(page.is_response_editable("response_other_author"))
|
||||
self.edit_response(page, "response_self_author")
|
||||
|
||||
@attr(shard=2)
|
||||
def test_edit_response_as_moderator(self):
|
||||
"""
|
||||
Scenario: Moderator should be able to edit the response they created and responses of other users
|
||||
Given that I am on discussion page with moderator logged in
|
||||
When I try to edit the response created by moderator
|
||||
Then the response should be edited and rendered successfully
|
||||
And I try to edit the response created by other users
|
||||
Then the response should be edited and rendered successfully
|
||||
"""
|
||||
self.setup_user(roles=["Moderator"])
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("response_edit_test_thread")
|
||||
page.visit()
|
||||
self.edit_response(page, "response_self_author")
|
||||
self.edit_response(page, "response_other_author")
|
||||
|
||||
@attr(shard=2)
|
||||
def test_vote_report_endorse_after_edit(self):
|
||||
"""
|
||||
Scenario: Moderator should be able to vote, report or endorse after editing the response.
|
||||
Given that I am on discussion page with moderator logged in
|
||||
When I try to edit the response created by moderator
|
||||
Then the response should be edited and rendered successfully
|
||||
And I try to edit the response created by other users
|
||||
Then the response should be edited and rendered successfully
|
||||
And I try to vote the response created by moderator
|
||||
Then the response should not be able to be voted
|
||||
And I try to vote the response created by other users
|
||||
Then the response should be voted successfully
|
||||
And I try to report the response created by moderator
|
||||
Then the response should not be able to be reported
|
||||
And I try to report the response created by other users
|
||||
Then the response should be reported successfully
|
||||
And I try to endorse the response created by moderator
|
||||
Then the response should be endorsed successfully
|
||||
And I try to endorse the response created by other users
|
||||
Then the response should be endorsed successfully
|
||||
"""
|
||||
self.setup_user(roles=["Moderator"])
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("response_edit_test_thread")
|
||||
page.visit()
|
||||
self.edit_response(page, "response_self_author")
|
||||
self.edit_response(page, "response_other_author")
|
||||
page.cannot_vote_response('response_self_author')
|
||||
page.vote_response('response_other_author')
|
||||
page.cannot_report_response('response_self_author')
|
||||
page.report_response('response_other_author')
|
||||
page.endorse_response('response_self_author')
|
||||
page.endorse_response('response_other_author')
|
||||
|
||||
@attr('a11y')
|
||||
def test_page_accessibility(self):
|
||||
self.setup_user()
|
||||
@@ -864,76 +271,6 @@ class DiscussionCommentEditTest(BaseDiscussionTestCase):
|
||||
[Comment(id="comment_other_author", user_id="other"), Comment(id="comment_self_author", user_id=self.user_id)])
|
||||
view.push()
|
||||
|
||||
def edit_comment(self, page, comment_id):
|
||||
page.start_comment_edit(comment_id)
|
||||
new_comment = "edited body"
|
||||
page.set_comment_editor_value(comment_id, new_comment)
|
||||
page.submit_comment_edit(comment_id, new_comment)
|
||||
|
||||
@attr(shard=2)
|
||||
def test_edit_comment_as_student(self):
|
||||
self.setup_user()
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("comment_edit_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_comment_editable("comment_self_author"))
|
||||
self.assertTrue(page.is_comment_visible("comment_other_author"))
|
||||
self.assertFalse(page.is_comment_editable("comment_other_author"))
|
||||
self.edit_comment(page, "comment_self_author")
|
||||
|
||||
@attr(shard=2)
|
||||
def test_edit_comment_as_moderator(self):
|
||||
self.setup_user(roles=["Moderator"])
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("comment_edit_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_comment_editable("comment_self_author"))
|
||||
self.assertTrue(page.is_comment_editable("comment_other_author"))
|
||||
self.edit_comment(page, "comment_self_author")
|
||||
self.edit_comment(page, "comment_other_author")
|
||||
|
||||
@attr(shard=2)
|
||||
def test_cancel_comment_edit(self):
|
||||
self.setup_user()
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("comment_edit_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_comment_editable("comment_self_author"))
|
||||
original_body = page.get_comment_body("comment_self_author")
|
||||
page.start_comment_edit("comment_self_author")
|
||||
page.set_comment_editor_value("comment_self_author", "edited body")
|
||||
page.cancel_comment_edit("comment_self_author", original_body)
|
||||
|
||||
@attr(shard=2)
|
||||
def test_editor_visibility(self):
|
||||
"""Only one editor should be visible at a time within a single response"""
|
||||
self.setup_user(roles=["Moderator"])
|
||||
self.setup_view()
|
||||
page = self.create_single_thread_page("comment_edit_test_thread")
|
||||
page.visit()
|
||||
self.assertTrue(page.is_comment_editable("comment_self_author"))
|
||||
self.assertTrue(page.is_comment_editable("comment_other_author"))
|
||||
self.assertTrue(page.is_add_comment_visible("response1"))
|
||||
original_body = page.get_comment_body("comment_self_author")
|
||||
page.start_comment_edit("comment_self_author")
|
||||
self.assertFalse(page.is_add_comment_visible("response1"))
|
||||
self.assertTrue(page.is_comment_editor_visible("comment_self_author"))
|
||||
page.set_comment_editor_value("comment_self_author", "edited body")
|
||||
page.start_comment_edit("comment_other_author")
|
||||
self.assertFalse(page.is_comment_editor_visible("comment_self_author"))
|
||||
self.assertTrue(page.is_comment_editor_visible("comment_other_author"))
|
||||
self.assertEqual(page.get_comment_body("comment_self_author"), original_body)
|
||||
page.start_response_edit("response1")
|
||||
self.assertFalse(page.is_comment_editor_visible("comment_other_author"))
|
||||
self.assertTrue(page.is_response_editor_visible("response1"))
|
||||
original_body = page.get_comment_body("comment_self_author")
|
||||
page.start_comment_edit("comment_self_author")
|
||||
self.assertFalse(page.is_response_editor_visible("response1"))
|
||||
self.assertTrue(page.is_comment_editor_visible("comment_self_author"))
|
||||
page.cancel_comment_edit("comment_self_author", original_body)
|
||||
self.assertFalse(page.is_comment_editor_visible("comment_self_author"))
|
||||
self.assertTrue(page.is_add_comment_visible("response1"))
|
||||
|
||||
@attr('a11y')
|
||||
def test_page_accessibility(self):
|
||||
self.setup_user()
|
||||
@@ -950,138 +287,6 @@ class DiscussionCommentEditTest(BaseDiscussionTestCase):
|
||||
})
|
||||
page.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
|
||||
@attr(shard=2)
|
||||
class DiscussionEditorPreviewTest(UniqueCourseTest):
|
||||
def setUp(self):
|
||||
super(DiscussionEditorPreviewTest, self).setUp()
|
||||
CourseFixture(**self.course_info).install()
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
self.page = DiscussionTabHomePage(self.browser, self.course_id)
|
||||
self.page.visit()
|
||||
self.page.click_new_post_button()
|
||||
|
||||
# sleep/wait added to allow Major MathJax a11y files to load
|
||||
time.sleep(5)
|
||||
|
||||
def test_text_rendering(self):
|
||||
"""When I type plain text into the editor, it should be rendered as plain text in the preview box"""
|
||||
self.page.set_new_post_editor_value("Some plain text")
|
||||
self.assertEqual(self.page.get_new_post_preview_value(), "<p>Some plain text</p>")
|
||||
|
||||
def test_markdown_rendering(self):
|
||||
"""When I type Markdown into the editor, it should be rendered as formatted Markdown in the preview box"""
|
||||
self.page.set_new_post_editor_value(
|
||||
"Some markdown\n"
|
||||
"\n"
|
||||
"- line 1\n"
|
||||
"- line 2"
|
||||
)
|
||||
|
||||
self.assertEqual(self.page.get_new_post_preview_value(), (
|
||||
"<p>Some markdown</p>\n"
|
||||
"\n"
|
||||
"<ul>\n"
|
||||
"<li>line 1</li>\n"
|
||||
"<li>line 2</li>\n"
|
||||
"</ul>"
|
||||
))
|
||||
|
||||
def test_mathjax_rendering_in_order(self):
|
||||
"""
|
||||
Tests that mathjax is rendered in proper order.
|
||||
|
||||
When user types mathjax expressions into discussion editor, it should render in the proper
|
||||
order.
|
||||
"""
|
||||
self.page.set_new_post_editor_value(
|
||||
'Text line 1 \n'
|
||||
'$$e[n]=d_1$$ \n'
|
||||
'Text line 2 \n'
|
||||
'$$e[n]=d_2$$'
|
||||
)
|
||||
self.assertEqual(self.page.get_new_post_preview_text(),
|
||||
'Text line 1\ne[n]=\nd\n1\nText line 2\ne[n]=\nd\n2'
|
||||
)
|
||||
|
||||
def test_inline_mathjax_rendering_in_order(self):
|
||||
"""
|
||||
Tests the order of Post body content when inline Mathjax is used.
|
||||
|
||||
With inline mathjax expressions, the text content doesn't break into new lines at the places of
|
||||
mathjax expressions.
|
||||
"""
|
||||
self.page.set_new_post_editor_value(
|
||||
'Text line 1 \n'
|
||||
'$e[n]=d_1$ \n'
|
||||
'Text line 2 \n'
|
||||
'$e[n]=d_2$'
|
||||
)
|
||||
self.assertEqual(self.page.get_new_post_preview_text('.wmd-preview > p'),
|
||||
'Text line 1\ne[n]=\nd\n1\nText line 2\ne[n]=\nd\n2'
|
||||
)
|
||||
|
||||
def test_mathjax_not_rendered_after_post_cancel(self):
|
||||
"""
|
||||
Tests that mathjax is not rendered when we cancel the post
|
||||
|
||||
When user types the mathjax expression into discussion editor, it will appear in te preview
|
||||
box, and when user cancel it and again click the "Add new post" button, mathjax will not
|
||||
appear in the preview box
|
||||
"""
|
||||
self.page.set_new_post_editor_value(
|
||||
six.text_type(
|
||||
r'\begin{equation}'
|
||||
r'\tau_g(\omega) = - \frac{d}{d\omega}\phi(\omega) \hspace{2em} (1) '
|
||||
r'\end{equation}'
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(self.page.get_new_post_preview_text())
|
||||
self.page.click_element(".cancel")
|
||||
alert = get_modal_alert(self.browser)
|
||||
alert.accept()
|
||||
self.assertIsNotNone(self.page.new_post_button)
|
||||
self.page.click_new_post_button()
|
||||
self.assertEqual(self.page.get_new_post_preview_value('.wmd-preview'), "")
|
||||
|
||||
|
||||
@attr(shard=2)
|
||||
class InlineDiscussionTest(UniqueCourseTest):
|
||||
"""
|
||||
Tests for inline discussions
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(InlineDiscussionTest, self).setUp()
|
||||
self.thread_ids = []
|
||||
self.discussion_id = "test_discussion_{}".format(uuid4().hex)
|
||||
self.additional_discussion_id = "test_discussion_{}".format(uuid4().hex)
|
||||
self.course_fix = CourseFixture(**self.course_info).add_children(
|
||||
XBlockFixtureDesc("chapter", "Test Section").add_children(
|
||||
XBlockFixtureDesc("sequential", "Test Subsection").add_children(
|
||||
XBlockFixtureDesc("vertical", "Test Unit").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"discussion",
|
||||
"Test Discussion",
|
||||
metadata={"discussion_id": self.discussion_id}
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
"discussion",
|
||||
"Test Discussion 1",
|
||||
metadata={"discussion_id": self.additional_discussion_id}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
self.user_id = AutoAuthPage(self.browser, course_id=self.course_id).visit().get_user_id()
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.discussion_page = InlineDiscussionPage(self.browser, self.discussion_id)
|
||||
self.additional_discussion_page = InlineDiscussionPage(self.browser, self.additional_discussion_id)
|
||||
|
||||
@attr('a11y')
|
||||
@pytest.mark.skip(reason='This test is too flaky to run at all. TNL-6215')
|
||||
def test_inline_a11y(self):
|
||||
@@ -1107,208 +312,6 @@ class InlineDiscussionTest(UniqueCourseTest):
|
||||
self.discussion_page.click_new_post_button()
|
||||
self.discussion_page.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
def test_add_a_post_is_present_if_can_create_thread_when_expanded(self):
|
||||
self.discussion_page.expand_discussion()
|
||||
# Add a Post link is present
|
||||
self.assertTrue(self.discussion_page.q(css='.new-post-btn').present)
|
||||
|
||||
def test_add_post_not_present_if_discussion_blackout_period_started(self):
|
||||
"""
|
||||
If discussion blackout period has started Add a post button should not appear.
|
||||
"""
|
||||
self.start_discussion_blackout_period()
|
||||
self.browser.refresh()
|
||||
self.discussion_page.expand_discussion()
|
||||
self.assertFalse(self.discussion_page.is_new_post_button_visible())
|
||||
|
||||
def test_initial_render(self):
|
||||
self.assertTrue(self.discussion_page.is_discussion_expanded())
|
||||
|
||||
def test_discussion_empty(self):
|
||||
self.assertEqual(self.discussion_page.get_num_displayed_threads(), 0)
|
||||
|
||||
def test_dual_discussion_xblock(self):
|
||||
"""
|
||||
Scenario: Two discussion xblocks in one unit shouldn't override their actions
|
||||
Given that I'm on a courseware page where there are two inline discussion
|
||||
When I click on the first discussion block's new post button
|
||||
Then I should be shown only the new post form for the first block
|
||||
When I click on the second discussion block's new post button
|
||||
Then I should be shown both new post forms
|
||||
When I cancel the first form
|
||||
Then I should be shown only the new post form for the second block
|
||||
When I cancel the second form
|
||||
And I click on the first discussion block's new post button
|
||||
Then I should be shown only the new post form for the first block
|
||||
When I cancel the first form
|
||||
Then I should be shown none of the forms
|
||||
"""
|
||||
self.discussion_page.wait_for_page()
|
||||
self.additional_discussion_page.wait_for_page()
|
||||
|
||||
# Click to add a post to the first discussion
|
||||
self.discussion_page.click_new_post_button()
|
||||
|
||||
# Verify that only the first discussion's form is shown
|
||||
self.assertIsNotNone(self.discussion_page.new_post_form)
|
||||
self.assertIsNone(self.additional_discussion_page.new_post_form)
|
||||
|
||||
# Click to add a post to the second discussion
|
||||
self.additional_discussion_page.click_new_post_button()
|
||||
|
||||
# Verify that both discussion's forms are shown
|
||||
self.assertIsNotNone(self.discussion_page.new_post_form)
|
||||
self.assertIsNotNone(self.additional_discussion_page.new_post_form)
|
||||
|
||||
# Cancel the first form
|
||||
self.discussion_page.click_cancel_new_post()
|
||||
|
||||
# Verify that only the second discussion's form is shown
|
||||
self.assertIsNone(self.discussion_page.new_post_form)
|
||||
self.assertIsNotNone(self.additional_discussion_page.new_post_form)
|
||||
|
||||
# Cancel the second form and click to show the first one
|
||||
self.additional_discussion_page.click_cancel_new_post()
|
||||
self.discussion_page.click_new_post_button()
|
||||
|
||||
# Verify that only the first discussion's form is shown
|
||||
self.assertIsNotNone(self.discussion_page.new_post_form)
|
||||
self.assertIsNone(self.additional_discussion_page.new_post_form)
|
||||
|
||||
# Cancel the first form
|
||||
self.discussion_page.click_cancel_new_post()
|
||||
|
||||
# Verify that neither discussion's forms are shwon
|
||||
self.assertIsNone(self.discussion_page.new_post_form)
|
||||
self.assertIsNone(self.additional_discussion_page.new_post_form)
|
||||
|
||||
def start_discussion_blackout_period(self):
|
||||
"""
|
||||
Start discussion blackout period, starting 14 days before now to 2 days ago.
|
||||
"""
|
||||
now = datetime.datetime.now(UTC)
|
||||
self.course_fix.add_advanced_settings(
|
||||
{
|
||||
u"discussion_blackouts": {
|
||||
"value": [
|
||||
[
|
||||
(now - datetime.timedelta(days=14)).isoformat(),
|
||||
(now + datetime.timedelta(days=2)).isoformat()
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
self.course_fix._add_advanced_settings() # pylint: disable=protected-access
|
||||
|
||||
|
||||
@attr(shard=2)
|
||||
class DiscussionUserProfileTest(UniqueCourseTest):
|
||||
"""
|
||||
Tests for user profile page in discussion tab.
|
||||
"""
|
||||
|
||||
PAGE_SIZE = 20 # discussion.views.THREADS_PER_PAGE
|
||||
PROFILED_USERNAME = "profiled-user"
|
||||
|
||||
def setUp(self):
|
||||
super(DiscussionUserProfileTest, self).setUp()
|
||||
self.setup_course()
|
||||
# The following line creates a user enrolled in our course, whose
|
||||
# threads will be viewed, but not the one who will view the page.
|
||||
# It isn't necessary to log them in, but using the AutoAuthPage
|
||||
# saves a lot of code.
|
||||
self.profiled_user_id = self.setup_user(username=self.PROFILED_USERNAME)
|
||||
# now create a second user who will view the profile.
|
||||
self.user_id = self.setup_user()
|
||||
UserProfileViewFixture([]).push()
|
||||
|
||||
def setup_course(self):
|
||||
"""
|
||||
Set up the for the course discussion user-profile tests.
|
||||
"""
|
||||
return CourseFixture(**self.course_info).install()
|
||||
|
||||
def setup_user(self, roles=None, **user_info):
|
||||
"""
|
||||
Helper method to create and authenticate a user.
|
||||
"""
|
||||
roles_str = ''
|
||||
if roles:
|
||||
roles_str = ','.join(roles)
|
||||
return AutoAuthPage(self.browser, course_id=self.course_id, roles=roles_str, **user_info).visit().get_user_id()
|
||||
|
||||
def test_redirects_to_learner_profile(self):
|
||||
"""
|
||||
Scenario: Verify that learner-profile link is present on forum discussions page and we can navigate to it.
|
||||
|
||||
Given that I am on discussion forum user's profile page.
|
||||
And I can see a username on the page
|
||||
When I click on my username.
|
||||
Then I will be navigated to Learner Profile page.
|
||||
And I can my username on Learner Profile page
|
||||
"""
|
||||
learner_profile_page = LearnerProfilePage(self.browser, self.PROFILED_USERNAME)
|
||||
|
||||
page = DiscussionUserProfilePage(
|
||||
self.browser,
|
||||
self.course_id,
|
||||
self.profiled_user_id,
|
||||
self.PROFILED_USERNAME
|
||||
)
|
||||
page.visit()
|
||||
page.click_on_sidebar_username()
|
||||
|
||||
learner_profile_page.wait_for_page()
|
||||
self.assertTrue(learner_profile_page.field_is_visible('username'))
|
||||
|
||||
def test_learner_profile_roles(self):
|
||||
"""
|
||||
Test that on the learner profile page user roles are correctly listed according to the course.
|
||||
"""
|
||||
# Setup a learner with roles in a Course-A.
|
||||
expected_student_roles = ['Administrator', 'Community TA', 'Moderator', 'Student']
|
||||
self.profiled_user_id = self.setup_user(
|
||||
roles=expected_student_roles,
|
||||
username=self.PROFILED_USERNAME
|
||||
)
|
||||
|
||||
# Visit the page and verify the roles are listed correctly.
|
||||
page = DiscussionUserProfilePage(
|
||||
self.browser,
|
||||
self.course_id,
|
||||
self.profiled_user_id,
|
||||
self.PROFILED_USERNAME
|
||||
)
|
||||
page.visit()
|
||||
student_roles = page.get_user_roles()
|
||||
self.assertEqual(student_roles, ', '.join(expected_student_roles))
|
||||
|
||||
# Save the course_id of Course-A before setting up a new course.
|
||||
old_course_id = self.course_id
|
||||
|
||||
# Setup Course-B and set user do not have additional roles and test roles are displayed correctly.
|
||||
self.course_info['number'] = self.unique_id
|
||||
self.setup_course()
|
||||
new_course_id = self.course_id
|
||||
|
||||
# Set the user to have no extra role in the Course-B and verify the existing
|
||||
# user is updated.
|
||||
profiled_student_user_id = self.setup_user(roles=None, username=self.PROFILED_USERNAME)
|
||||
self.assertEqual(self.profiled_user_id, profiled_student_user_id)
|
||||
self.assertNotEqual(old_course_id, new_course_id)
|
||||
|
||||
# Visit the user profile in course discussion page of Course-B. Make sure the
|
||||
# roles are listed correctly.
|
||||
page = DiscussionUserProfilePage(
|
||||
self.browser,
|
||||
self.course_id,
|
||||
self.profiled_user_id,
|
||||
self.PROFILED_USERNAME
|
||||
)
|
||||
page.visit()
|
||||
self.assertEqual(page.get_user_roles(), u'Student')
|
||||
|
||||
|
||||
class DiscussionSearchAlertTest(UniqueCourseTest):
|
||||
"""
|
||||
@@ -1331,65 +334,6 @@ class DiscussionSearchAlertTest(UniqueCourseTest):
|
||||
self.page = DiscussionTabHomePage(self.browser, self.course_id)
|
||||
self.page.visit()
|
||||
|
||||
def setup_corrected_text(self, text):
|
||||
SearchResultFixture(SearchResult(corrected_text=text)).push()
|
||||
|
||||
def check_search_alert_messages(self, expected):
|
||||
actual = self.page.get_search_alert_messages()
|
||||
self.assertTrue(all(map(lambda msg, sub: msg.lower().find(sub.lower()) >= 0, actual, expected)))
|
||||
|
||||
@attr(shard=2)
|
||||
def test_no_rewrite(self):
|
||||
self.setup_corrected_text(None)
|
||||
self.page.perform_search()
|
||||
self.check_search_alert_messages(["no posts"])
|
||||
|
||||
@attr(shard=2)
|
||||
def test_rewrite_dismiss(self):
|
||||
self.page.dismiss_alert_message("There are no posts in this topic yet.")
|
||||
self.setup_corrected_text("foo")
|
||||
self.page.perform_search()
|
||||
self.check_search_alert_messages(["foo"])
|
||||
self.page.dismiss_alert_message("foo")
|
||||
self.check_search_alert_messages([])
|
||||
|
||||
@attr(shard=2)
|
||||
def test_new_search(self):
|
||||
self.page.dismiss_alert_message("There are no posts in this topic yet.")
|
||||
self.setup_corrected_text("foo")
|
||||
self.page.perform_search()
|
||||
self.check_search_alert_messages(["foo"])
|
||||
|
||||
self.setup_corrected_text("bar")
|
||||
self.page.perform_search()
|
||||
self.check_search_alert_messages(["bar"])
|
||||
|
||||
self.setup_corrected_text(None)
|
||||
self.page.perform_search()
|
||||
self.check_search_alert_messages(["no posts"])
|
||||
|
||||
@attr(shard=2)
|
||||
def test_rewrite_and_user(self):
|
||||
self.page.dismiss_alert_message("There are no posts in this topic yet.")
|
||||
self.setup_corrected_text("foo")
|
||||
self.page.perform_search(self.SEARCHED_USERNAME)
|
||||
self.check_search_alert_messages(["foo", self.SEARCHED_USERNAME])
|
||||
|
||||
@attr(shard=2)
|
||||
def test_user_only(self):
|
||||
self.setup_corrected_text(None)
|
||||
self.page.perform_search(self.SEARCHED_USERNAME)
|
||||
self.check_search_alert_messages(["no posts", self.SEARCHED_USERNAME])
|
||||
# make sure clicking the link leads to the user profile page
|
||||
UserProfileViewFixture([]).push()
|
||||
self.page.get_search_alert_links().first.click()
|
||||
DiscussionUserProfilePage(
|
||||
self.browser,
|
||||
self.course_id,
|
||||
self.searched_user_id,
|
||||
self.SEARCHED_USERNAME
|
||||
).wait_for_page()
|
||||
|
||||
@attr('a11y')
|
||||
def test_page_accessibility(self):
|
||||
self.page.a11y_audit.config.set_rules({
|
||||
@@ -1401,57 +345,3 @@ class DiscussionSearchAlertTest(UniqueCourseTest):
|
||||
]
|
||||
})
|
||||
self.page.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
|
||||
@attr(shard=2)
|
||||
class DiscussionSortPreferenceTest(UniqueCourseTest):
|
||||
"""
|
||||
Tests for the discussion page displaying a single thread.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(DiscussionSortPreferenceTest, self).setUp()
|
||||
|
||||
# Create a course to register for.
|
||||
CourseFixture(**self.course_info).install()
|
||||
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
self.sort_page = DiscussionSortPreferencePage(self.browser, self.course_id)
|
||||
self.sort_page.visit()
|
||||
self.sort_page.show_all_discussions()
|
||||
|
||||
def test_default_sort_preference(self):
|
||||
"""
|
||||
Test to check the default sorting preference of user. (Default = date )
|
||||
"""
|
||||
selected_sort = self.sort_page.get_selected_sort_preference()
|
||||
self.assertEqual(selected_sort, "activity")
|
||||
|
||||
@skip_if_browser('chrome') # TODO TE-1542 and TE-1543
|
||||
def test_change_sort_preference(self):
|
||||
"""
|
||||
Test that if user sorting preference is changing properly.
|
||||
"""
|
||||
selected_sort = ""
|
||||
for sort_type in ["votes", "comments", "activity"]:
|
||||
self.assertNotEqual(selected_sort, sort_type)
|
||||
self.sort_page.change_sort_preference(sort_type)
|
||||
selected_sort = self.sort_page.get_selected_sort_preference()
|
||||
self.assertEqual(selected_sort, sort_type)
|
||||
|
||||
@skip_if_browser('chrome') # TODO TE-1542 and TE-1543
|
||||
def test_last_preference_saved(self):
|
||||
"""
|
||||
Test that user last preference is saved.
|
||||
"""
|
||||
selected_sort = ""
|
||||
for sort_type in ["votes", "comments", "activity"]:
|
||||
self.assertNotEqual(selected_sort, sort_type)
|
||||
self.sort_page.change_sort_preference(sort_type)
|
||||
selected_sort = self.sort_page.get_selected_sort_preference()
|
||||
self.assertEqual(selected_sort, sort_type)
|
||||
self.sort_page.refresh_page()
|
||||
self.sort_page.show_all_discussions()
|
||||
selected_sort = self.sort_page.get_selected_sort_preference()
|
||||
self.assertEqual(selected_sort, sort_type)
|
||||
|
||||
@@ -1,495 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
End-to-end tests related to the divided discussion management on the LMS Instructor Dashboard
|
||||
"""
|
||||
|
||||
|
||||
import uuid
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.common.utils import add_enrollment_course_modes
|
||||
from common.test.acceptance.pages.lms.discussion import DiscussionTabSingleThreadPage
|
||||
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage
|
||||
from common.test.acceptance.tests.discussion.helpers import BaseDiscussionMixin, CohortTestMixin
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest
|
||||
from openedx.core.lib.tests import attr
|
||||
|
||||
|
||||
class BaseDividedDiscussionTest(UniqueCourseTest, CohortTestMixin):
|
||||
"""
|
||||
Base class for tests related to divided discussions.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up a discussion topic
|
||||
"""
|
||||
super(BaseDividedDiscussionTest, self).setUp()
|
||||
|
||||
self.discussion_id = "test_discussion_{}".format(uuid.uuid4().hex)
|
||||
self.course_fixture = CourseFixture(**self.course_info).add_children(
|
||||
XBlockFixtureDesc("chapter", "Test Section").add_children(
|
||||
XBlockFixtureDesc("sequential", "Test Subsection").add_children(
|
||||
XBlockFixtureDesc("vertical", "Test Unit").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"discussion",
|
||||
"Test Discussion",
|
||||
metadata={"discussion_id": self.discussion_id}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
# create course with single cohort and two content groups (user_partition of type "cohort")
|
||||
self.cohort_name = "OnlyCohort"
|
||||
self.setup_cohort_config(self.course_fixture)
|
||||
self.cohort_id = self.add_manual_cohort(self.course_fixture, self.cohort_name)
|
||||
|
||||
# login as an instructor
|
||||
self.instructor_name = "instructor_user"
|
||||
self.instructor_id = AutoAuthPage(
|
||||
self.browser, username=self.instructor_name, email="instructor_user@example.com",
|
||||
course_id=self.course_id, staff=True
|
||||
).visit().get_user_id()
|
||||
|
||||
# go to the membership page on the instructor dashboard
|
||||
self.instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id)
|
||||
self.instructor_dashboard_page.visit()
|
||||
self.discussion_management_page = self.instructor_dashboard_page.select_discussion_management()
|
||||
self.discussion_management_page.wait_for_page()
|
||||
|
||||
self.course_wide_key = 'course-wide'
|
||||
self.inline_key = 'inline'
|
||||
self.scheme_key = 'scheme'
|
||||
|
||||
def check_discussion_topic_visibility(self, visible=True):
|
||||
"""
|
||||
Assert that discussion topics are visible with appropriate content.
|
||||
"""
|
||||
self.assertEqual(visible, self.discussion_management_page.discussion_topics_visible())
|
||||
|
||||
if visible:
|
||||
self.assertEqual(
|
||||
"Course-Wide Discussion Topics",
|
||||
self.discussion_management_page.divided_discussion_heading_is_visible(self.course_wide_key)
|
||||
)
|
||||
self.assertTrue(self.discussion_management_page.is_save_button_disabled(self.course_wide_key))
|
||||
|
||||
self.assertEqual(
|
||||
"Content-Specific Discussion Topics",
|
||||
self.discussion_management_page.divided_discussion_heading_is_visible(self.inline_key)
|
||||
)
|
||||
self.assertTrue(self.discussion_management_page.is_save_button_disabled(self.inline_key))
|
||||
|
||||
def reload_page(self, topics_visible=True):
|
||||
"""
|
||||
Refresh the page, then verify if the discussion topics are visible on the discussion
|
||||
management instructor dashboard tab.
|
||||
"""
|
||||
self.browser.refresh()
|
||||
self.discussion_management_page.wait_for_page()
|
||||
|
||||
self.instructor_dashboard_page.select_discussion_management()
|
||||
self.discussion_management_page.wait_for_page()
|
||||
|
||||
self.check_discussion_topic_visibility(topics_visible)
|
||||
|
||||
def verify_save_confirmation_message(self, key):
|
||||
"""
|
||||
Verify that the save confirmation message for the specified portion of the page is visible.
|
||||
"""
|
||||
confirmation_message = self.discussion_management_page.get_divide_discussions_message(key=key)
|
||||
self.assertIn("Your changes have been saved.", confirmation_message)
|
||||
|
||||
|
||||
@attr(shard=15)
|
||||
class DividedDiscussionTopicsTest(BaseDividedDiscussionTest):
|
||||
"""
|
||||
Tests for dividing the inline and course-wide discussion topics.
|
||||
"""
|
||||
|
||||
def save_and_verify_discussion_topics(self, key):
|
||||
"""
|
||||
Saves the discussion topics and the verify the changes.
|
||||
"""
|
||||
# click on the inline save button.
|
||||
self.discussion_management_page.save_discussion_topics(key)
|
||||
|
||||
# verifies that changes saved successfully.
|
||||
self.verify_save_confirmation_message(key)
|
||||
|
||||
# save button disabled again.
|
||||
self.assertTrue(self.discussion_management_page.is_save_button_disabled(key))
|
||||
|
||||
def verify_discussion_topics_after_reload(self, key, divided_topics):
|
||||
"""
|
||||
Verifies the changed topics.
|
||||
"""
|
||||
self.reload_page()
|
||||
self.assertEqual(self.discussion_management_page.get_divided_topics_count(key), divided_topics)
|
||||
|
||||
def test_divide_course_wide_discussion_topic(self):
|
||||
"""
|
||||
Scenario: divide a course-wide discussion topic.
|
||||
|
||||
Given I have a course with a divide defined,
|
||||
And a course-wide discussion with disabled Save button.
|
||||
When I click on the course-wide discussion topic
|
||||
Then I see the enabled save button
|
||||
When I click on save button
|
||||
Then I see success message
|
||||
When I reload the page
|
||||
Then I see the discussion topic selected
|
||||
"""
|
||||
self.check_discussion_topic_visibility()
|
||||
|
||||
divided_topics_before = self.discussion_management_page.get_divided_topics_count(self.course_wide_key)
|
||||
self.discussion_management_page.select_discussion_topic(self.course_wide_key)
|
||||
|
||||
self.assertFalse(self.discussion_management_page.is_save_button_disabled(self.course_wide_key))
|
||||
|
||||
self.save_and_verify_discussion_topics(key=self.course_wide_key)
|
||||
divided_topics_after = self.discussion_management_page.get_divided_topics_count(self.course_wide_key)
|
||||
|
||||
self.assertNotEqual(divided_topics_before, divided_topics_after)
|
||||
|
||||
self.verify_discussion_topics_after_reload(self.course_wide_key, divided_topics_after)
|
||||
|
||||
def test_always_divide_inline_topic_enabled(self):
|
||||
"""
|
||||
Scenario: Select the always_divide_inline_topics radio button
|
||||
|
||||
Given I have a course with a cohort defined,
|
||||
And an inline discussion topic with disabled Save button.
|
||||
When I click on always_divide_inline_topics
|
||||
Then I see enabled save button
|
||||
And I see disabled inline discussion topics
|
||||
When I save the change
|
||||
And I reload the page
|
||||
Then I see the always_divide_inline_topics option enabled
|
||||
"""
|
||||
self.check_discussion_topic_visibility()
|
||||
|
||||
# enable always inline discussion topics and save the change
|
||||
self.discussion_management_page.select_always_inline_discussion()
|
||||
self.assertFalse(self.discussion_management_page.is_save_button_disabled(self.inline_key))
|
||||
self.assertTrue(self.discussion_management_page.inline_discussion_topics_disabled())
|
||||
self.discussion_management_page.save_discussion_topics(key=self.inline_key)
|
||||
|
||||
self.reload_page()
|
||||
self.assertTrue(self.discussion_management_page.always_inline_discussion_selected())
|
||||
|
||||
def test_divide_some_inline_topics_enabled(self):
|
||||
"""
|
||||
Scenario: Select the divide_some_inline_topics radio button
|
||||
|
||||
Given I have a course with a divide defined and always_divide_inline_topics set to True
|
||||
And an inline discussion topic with disabled Save button.
|
||||
When I click on divide_some_inline_topics
|
||||
Then I see enabled save button
|
||||
And I see enabled inline discussion topics
|
||||
When I save the change
|
||||
And I reload the page
|
||||
Then I see the divide_some_inline_topics option enabled
|
||||
"""
|
||||
self.check_discussion_topic_visibility()
|
||||
# By default always inline discussion topics is False. Enable it (and reload the page).
|
||||
self.assertFalse(self.discussion_management_page.always_inline_discussion_selected())
|
||||
self.discussion_management_page.select_always_inline_discussion()
|
||||
self.discussion_management_page.save_discussion_topics(key=self.inline_key)
|
||||
self.reload_page()
|
||||
self.assertFalse(self.discussion_management_page.divide_some_inline_discussion_selected())
|
||||
|
||||
# enable some inline discussion topic radio button.
|
||||
self.discussion_management_page.select_divide_some_inline_discussion()
|
||||
# I see that save button is enabled
|
||||
self.assertFalse(self.discussion_management_page.is_save_button_disabled(self.inline_key))
|
||||
# I see that inline discussion topics are enabled
|
||||
self.assertFalse(self.discussion_management_page.inline_discussion_topics_disabled())
|
||||
self.discussion_management_page.save_discussion_topics(key=self.inline_key)
|
||||
|
||||
self.reload_page()
|
||||
self.assertTrue(self.discussion_management_page.divide_some_inline_discussion_selected())
|
||||
|
||||
def test_divide_inline_discussion_topic(self):
|
||||
"""
|
||||
Scenario: divide inline discussion topic.
|
||||
|
||||
Given I have a course with a divide defined,
|
||||
And a inline discussion topic with disabled Save button
|
||||
And When I click on inline discussion topic
|
||||
And I see enabled save button
|
||||
And When i click save button
|
||||
Then I see success message
|
||||
When I reload the page
|
||||
Then I see the discussion topic selected
|
||||
"""
|
||||
self.check_discussion_topic_visibility()
|
||||
|
||||
divided_topics_before = self.discussion_management_page.get_divided_topics_count(self.inline_key)
|
||||
# check the discussion topic.
|
||||
self.discussion_management_page.select_discussion_topic(self.inline_key)
|
||||
|
||||
# Save button enabled.
|
||||
self.assertFalse(self.discussion_management_page.is_save_button_disabled(self.inline_key))
|
||||
|
||||
# verifies that changes saved successfully.
|
||||
self.save_and_verify_discussion_topics(key=self.inline_key)
|
||||
|
||||
divided_topics_after = self.discussion_management_page.get_divided_topics_count(self.inline_key)
|
||||
self.assertNotEqual(divided_topics_before, divided_topics_after)
|
||||
|
||||
self.verify_discussion_topics_after_reload(self.inline_key, divided_topics_after)
|
||||
|
||||
def test_verify_that_selecting_the_final_child_selects_category(self):
|
||||
"""
|
||||
Scenario: Category should be selected on selecting final child.
|
||||
|
||||
Given I have a course with a cohort defined,
|
||||
And a inline discussion with disabled Save button.
|
||||
When I click on child topics
|
||||
Then I see enabled saved button
|
||||
Then I see parent category to be checked.
|
||||
"""
|
||||
self.check_discussion_topic_visibility()
|
||||
|
||||
# category should not be selected.
|
||||
self.assertFalse(self.discussion_management_page.is_category_selected())
|
||||
|
||||
# check the discussion topic.
|
||||
self.discussion_management_page.select_discussion_topic(self.inline_key)
|
||||
|
||||
# verify that category is selected.
|
||||
self.assertTrue(self.discussion_management_page.is_category_selected())
|
||||
|
||||
def test_verify_that_deselecting_the_final_child_deselects_category(self):
|
||||
"""
|
||||
Scenario: Category should be deselected on deselecting final child.
|
||||
|
||||
Given I have a course with a cohort defined,
|
||||
And a inline discussion with disabled Save button.
|
||||
When I click on final child topics
|
||||
Then I see enabled saved button
|
||||
Then I see parent category to be deselected.
|
||||
"""
|
||||
self.check_discussion_topic_visibility()
|
||||
|
||||
# category should not be selected.
|
||||
self.assertFalse(self.discussion_management_page.is_category_selected())
|
||||
|
||||
# check the discussion topic.
|
||||
self.discussion_management_page.select_discussion_topic(self.inline_key)
|
||||
|
||||
# verify that category is selected.
|
||||
self.assertTrue(self.discussion_management_page.is_category_selected())
|
||||
|
||||
# un-check the discussion topic.
|
||||
self.discussion_management_page.select_discussion_topic(self.inline_key)
|
||||
|
||||
# category should not be selected.
|
||||
self.assertFalse(self.discussion_management_page.is_category_selected())
|
||||
|
||||
|
||||
@attr(shard=6)
|
||||
class DivisionSchemeTest(BaseDividedDiscussionTest, BaseDiscussionMixin):
|
||||
"""
|
||||
Tests for changing the division scheme for Discussions.
|
||||
"""
|
||||
|
||||
def add_modes_and_view_discussion_mgmt_page(self, modes):
|
||||
"""
|
||||
Adds enrollment modes to the course, and then goes to the
|
||||
discussion tab on the instructor dashboard.
|
||||
"""
|
||||
add_enrollment_course_modes(self.browser, self.course_id, modes)
|
||||
self.view_discussion_management_page()
|
||||
|
||||
def view_discussion_management_page(self):
|
||||
"""
|
||||
Go to the discussion tab on the instructor dashboard.
|
||||
"""
|
||||
self.instructor_dashboard_page.visit()
|
||||
self.assertTrue(self.instructor_dashboard_page.is_discussion_management_visible())
|
||||
self.instructor_dashboard_page.select_discussion_management()
|
||||
self.discussion_management_page.wait_for_page()
|
||||
|
||||
def setup_thread_page(self, thread_id):
|
||||
"""
|
||||
This is called by BaseDiscussionMixin.setup_thread.
|
||||
"""
|
||||
self.thread_page = DiscussionTabSingleThreadPage(
|
||||
self.browser, self.course_id, self.discussion_id, thread_id
|
||||
)
|
||||
self.thread_page.visit()
|
||||
|
||||
def test_not_divided_hides_discussion_topics(self):
|
||||
"""
|
||||
Tests that discussion topics are hidden iff discussion division is disabled.
|
||||
"""
|
||||
# Initially "Cohort" is the selected scheme.
|
||||
self.assertTrue(
|
||||
self.discussion_management_page.division_scheme_visible(self.discussion_management_page.COHORT_SCHEME)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.discussion_management_page.COHORT_SCHEME,
|
||||
self.discussion_management_page.get_selected_scheme()
|
||||
)
|
||||
self.check_discussion_topic_visibility(visible=True)
|
||||
|
||||
self.discussion_management_page.select_division_scheme(self.discussion_management_page.NOT_DIVIDED_SCHEME)
|
||||
self.verify_save_confirmation_message(self.scheme_key)
|
||||
self.check_discussion_topic_visibility(visible=False)
|
||||
|
||||
# Reload the page and make sure that the change was persisted
|
||||
self.reload_page(topics_visible=False)
|
||||
self.assertTrue(self.discussion_management_page.division_scheme_visible(
|
||||
self.discussion_management_page.COHORT_SCHEME)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.discussion_management_page.NOT_DIVIDED_SCHEME,
|
||||
self.discussion_management_page.get_selected_scheme()
|
||||
)
|
||||
|
||||
# Select "cohort" again and make sure that the discussion topics appear.
|
||||
self.discussion_management_page.select_division_scheme(self.discussion_management_page.COHORT_SCHEME)
|
||||
self.verify_save_confirmation_message(self.scheme_key)
|
||||
self.check_discussion_topic_visibility(visible=True)
|
||||
|
||||
def test_disabling_cohorts(self):
|
||||
"""
|
||||
Test that the discussions management tab hides when there is <= 1 enrollment track, the Cohort division scheme
|
||||
is not selected, and cohorts are disabled.
|
||||
(even without reloading the page).
|
||||
"""
|
||||
self.disable_cohorting(self.course_fixture)
|
||||
self.instructor_dashboard_page.visit()
|
||||
self.assertFalse(self.instructor_dashboard_page.is_discussion_management_visible())
|
||||
|
||||
def test_disabling_cohorts_while_selected(self):
|
||||
"""
|
||||
Test that disabling cohorts does not hide the discussion tab when there is more than one enrollment track.
|
||||
Also that the division scheme for cohorts is visible iff it was selected.
|
||||
(even without reloading the page).
|
||||
"""
|
||||
add_enrollment_course_modes(self.browser, self.course_id, ['audit', 'verified'])
|
||||
|
||||
# Verify that the tab is visible, the cohort scheme is selected by default for divided discussions
|
||||
self.disable_cohorting(self.course_fixture)
|
||||
|
||||
# Go to Discussions tab and ensure that the correct scheme options are visible
|
||||
self.view_discussion_management_page()
|
||||
self.assertTrue(
|
||||
self.discussion_management_page.division_scheme_visible(
|
||||
self.discussion_management_page.COHORT_SCHEME
|
||||
)
|
||||
)
|
||||
|
||||
def test_disabling_cohorts_while_not_selected(self):
|
||||
"""
|
||||
Test that disabling cohorts does not hide the discussion tab when there is more than one enrollment track.
|
||||
Also that the division scheme for cohorts is not visible when cohorts are disabled and another scheme is
|
||||
selected for division.
|
||||
(even without reloading the page).
|
||||
"""
|
||||
add_enrollment_course_modes(self.browser, self.course_id, ['audit', 'verified'])
|
||||
|
||||
# Verify that the tab is visible
|
||||
self.view_discussion_management_page()
|
||||
self.discussion_management_page.select_division_scheme(self.discussion_management_page.ENROLLMENT_TRACK_SCHEME)
|
||||
self.verify_save_confirmation_message(self.scheme_key)
|
||||
self.disable_cohorting(self.course_fixture)
|
||||
|
||||
# Go to Discussions tab and ensure that the correct scheme options are visible
|
||||
self.view_discussion_management_page()
|
||||
self.assertFalse(
|
||||
self.discussion_management_page.division_scheme_visible(
|
||||
self.discussion_management_page.COHORT_SCHEME
|
||||
)
|
||||
)
|
||||
|
||||
def test_single_enrollment_mode(self):
|
||||
"""
|
||||
Test that the enrollment track scheme is not visible if there is a single enrollment mode.
|
||||
"""
|
||||
self.add_modes_and_view_discussion_mgmt_page(['audit'])
|
||||
self.assertFalse(
|
||||
self.discussion_management_page.division_scheme_visible(
|
||||
self.discussion_management_page.ENROLLMENT_TRACK_SCHEME
|
||||
)
|
||||
)
|
||||
|
||||
def test_radio_buttons_with_multiple_enrollment_modes(self):
|
||||
"""
|
||||
Test that the enrollment track scheme is visible if there are multiple enrollment tracks,
|
||||
and that the selection can be persisted.
|
||||
|
||||
Also verifies that the cohort division scheme is not presented if cohorts are disabled and cohorts
|
||||
are not the selected division scheme.
|
||||
"""
|
||||
self.add_modes_and_view_discussion_mgmt_page(['audit', 'verified'])
|
||||
self.assertTrue(
|
||||
self.discussion_management_page.division_scheme_visible(
|
||||
self.discussion_management_page.ENROLLMENT_TRACK_SCHEME
|
||||
)
|
||||
)
|
||||
# And the cohort scheme is initially visible because it is selected (and cohorts are enabled).
|
||||
self.assertTrue(
|
||||
self.discussion_management_page.division_scheme_visible(self.discussion_management_page.COHORT_SCHEME)
|
||||
)
|
||||
|
||||
self.discussion_management_page.select_division_scheme(self.discussion_management_page.ENROLLMENT_TRACK_SCHEME)
|
||||
self.verify_save_confirmation_message(self.scheme_key)
|
||||
self.check_discussion_topic_visibility(visible=True)
|
||||
|
||||
# Also disable cohorts so we can verify that the cohort scheme choice goes away.
|
||||
self.disable_cohorting(self.course_fixture)
|
||||
|
||||
self.reload_page(topics_visible=True)
|
||||
self.assertEqual(
|
||||
self.discussion_management_page.ENROLLMENT_TRACK_SCHEME,
|
||||
self.discussion_management_page.get_selected_scheme()
|
||||
)
|
||||
# Verify that the cohort scheme is no longer visible as cohorts are disabled.
|
||||
self.assertFalse(
|
||||
self.discussion_management_page.division_scheme_visible(self.discussion_management_page.COHORT_SCHEME)
|
||||
)
|
||||
|
||||
def test_enrollment_track_discussion_visibility_label(self):
|
||||
"""
|
||||
If enrollment tracks are the division scheme, verifies that discussion visibility labels
|
||||
correctly render.
|
||||
|
||||
Note that there are similar tests for cohorts in test_cohorts.py.
|
||||
"""
|
||||
def refresh_thread_page():
|
||||
self.browser.refresh()
|
||||
self.thread_page.wait_for_page()
|
||||
|
||||
# Make moderator for viewing all groups in discussions.
|
||||
AutoAuthPage(self.browser, course_id=self.course_id, roles="Moderator", staff=True).visit()
|
||||
|
||||
self.add_modes_and_view_discussion_mgmt_page(['audit', 'verified'])
|
||||
self.discussion_management_page.select_division_scheme(self.discussion_management_page.ENROLLMENT_TRACK_SCHEME)
|
||||
self.verify_save_confirmation_message(self.scheme_key)
|
||||
# Set "always divide" as the thread we will be creating will be an inline thread,
|
||||
# and this way the thread does not need to be explicitly divided.
|
||||
self.enable_always_divide_inline_discussions(self.course_fixture)
|
||||
|
||||
# Create a thread with group_id corresponding to the Audit enrollment mode.
|
||||
# The Audit group ID is 1, and for the comment service group_id we negate it.
|
||||
self.setup_thread(1, group_id=-1)
|
||||
|
||||
refresh_thread_page()
|
||||
self.assertEqual(
|
||||
self.thread_page.get_group_visibility_label(),
|
||||
u"This post is visible only to {}.".format("Audit")
|
||||
)
|
||||
|
||||
# Disable dividing discussions and verify that the post now shows as visible to everyone.
|
||||
self.view_discussion_management_page()
|
||||
self.discussion_management_page.select_division_scheme(self.discussion_management_page.NOT_DIVIDED_SCHEME)
|
||||
self.verify_save_confirmation_message(self.scheme_key)
|
||||
|
||||
self.thread_page.visit()
|
||||
self.assertEqual(self.thread_page.get_group_visibility_label(), "This post is visible to everyone.")
|
||||
@@ -4,14 +4,10 @@ Test helper functions and base classes.
|
||||
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import io
|
||||
import json
|
||||
import operator
|
||||
import os
|
||||
import pprint
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from unittest import SkipTest, TestCase
|
||||
|
||||
@@ -26,17 +22,13 @@ from path import Path as path
|
||||
from pymongo import ASCENDING, MongoClient
|
||||
from selenium.common.exceptions import StaleElementReferenceException
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.select import Select
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from six.moves import range, zip
|
||||
|
||||
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
|
||||
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common import BASE_URL
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from openedx.core.lib.tests.assertions.events import EventMatchTolerates, assert_event_matches, is_matching_event
|
||||
from openedx.core.release import RELEASE_LINE, doc_version
|
||||
from xmodule.partitions.partitions import UserPartition
|
||||
|
||||
MAX_EVENTS_IN_FAILURE_OUTPUT = 20
|
||||
@@ -307,72 +299,6 @@ def select_option_by_value(browser_query, value, focus_out=False):
|
||||
EmptyPromise(options_selected, "Option is selected").fulfill()
|
||||
|
||||
|
||||
def is_option_value_selected(browser_query, value):
|
||||
"""
|
||||
return true if given value is selected in html select element, else return false.
|
||||
"""
|
||||
select = Select(browser_query.first.results[0])
|
||||
ddl_selected_value = select.first_selected_option.get_attribute('value')
|
||||
return ddl_selected_value == value
|
||||
|
||||
|
||||
def element_has_text(page, css_selector, text):
|
||||
"""
|
||||
Return true if the given text is present in the list.
|
||||
"""
|
||||
text_present = False
|
||||
text_list = page.q(css=css_selector).text
|
||||
|
||||
if text_list and (text in text_list):
|
||||
text_present = True
|
||||
|
||||
return text_present
|
||||
|
||||
|
||||
def get_modal_alert(browser):
|
||||
"""
|
||||
Returns instance of modal alert box shown in browser after waiting
|
||||
for 6 seconds
|
||||
"""
|
||||
WebDriverWait(browser, 6).until(EC.alert_is_present())
|
||||
return browser.switch_to.alert
|
||||
|
||||
|
||||
def get_element_padding(page, selector):
|
||||
"""
|
||||
Get Padding of the element with given selector,
|
||||
|
||||
:returns a dict object with the following keys.
|
||||
1 - padding-top
|
||||
2 - padding-right
|
||||
3 - padding-bottom
|
||||
4 - padding-left
|
||||
|
||||
Example Use:
|
||||
progress_page.get_element_padding('.wrapper-msg.wrapper-auto-cert')
|
||||
|
||||
"""
|
||||
js_script = u"""
|
||||
var $element = $('%(selector)s');
|
||||
|
||||
element_padding = {
|
||||
'padding-top': $element.css('padding-top').replace("px", ""),
|
||||
'padding-right': $element.css('padding-right').replace("px", ""),
|
||||
'padding-bottom': $element.css('padding-bottom').replace("px", ""),
|
||||
'padding-left': $element.css('padding-left').replace("px", "")
|
||||
};
|
||||
|
||||
return element_padding;
|
||||
""" % {'selector': selector}
|
||||
|
||||
return page.browser.execute_script(js_script)
|
||||
|
||||
|
||||
def is_404_page(browser):
|
||||
""" Check if page is 404 """
|
||||
return 'Page not found (404)' in browser.find_element_by_tag_name('h1').text
|
||||
|
||||
|
||||
def create_multiple_choice_xml(correct_choice=2, num_choices=4):
|
||||
"""
|
||||
Return the Multiple Choice Problem XML, given the name of the problem.
|
||||
@@ -411,55 +337,6 @@ def auto_auth(browser, username, email, staff, course_id, **kwargs):
|
||||
AutoAuthPage(browser, username=username, email=email, course_id=course_id, staff=staff, **kwargs).visit()
|
||||
|
||||
|
||||
def assert_link(test, expected_link, actual_link):
|
||||
"""
|
||||
Assert that 'href' and text inside help DOM element are correct.
|
||||
|
||||
Arguments:
|
||||
test: Test on which links are being tested.
|
||||
expected_link (dict): The expected link attributes.
|
||||
actual_link (dict): The actual link attribute on page.
|
||||
"""
|
||||
test.assertEqual(expected_link['href'], actual_link.get_attribute('href'))
|
||||
test.assertEqual(expected_link['text'], actual_link.text)
|
||||
|
||||
|
||||
def assert_opened_help_link_is_correct(test, url):
|
||||
"""
|
||||
Asserts that url of browser when help link is clicked is correct.
|
||||
Arguments:
|
||||
test (AcceptanceTest): test calling this method.
|
||||
url (str): url to verify.
|
||||
"""
|
||||
test.browser.switch_to_window(test.browser.window_handles[-1])
|
||||
WebDriverWait(test.browser, 10).until(lambda driver: driver.current_url == url)
|
||||
# Check that the URL loads. Can't do this in the browser because it might
|
||||
# be loading a "Maze Found" missing content page.
|
||||
response = requests.get(url)
|
||||
test.assertEqual(response.status_code, 200, u"URL {!r} returned {}".format(url, response.status_code))
|
||||
|
||||
|
||||
EDX_BOOKS = {
|
||||
'course_author': 'edx-partner-course-staff',
|
||||
'learner': 'edx-guide-for-students',
|
||||
}
|
||||
|
||||
OPEN_BOOKS = {
|
||||
'course_author': 'open-edx-building-and-running-a-course',
|
||||
'learner': 'open-edx-learner-guide',
|
||||
}
|
||||
|
||||
|
||||
def url_for_help(book_slug, path_component):
|
||||
"""
|
||||
Create a full help URL given a book slug and a path component.
|
||||
"""
|
||||
# Emulate the switch between books that happens in envs/bokchoy.py
|
||||
books = EDX_BOOKS if RELEASE_LINE == "master" else OPEN_BOOKS
|
||||
url = 'https://edx.readthedocs.io/projects/{}/en/{}{}'.format(books[book_slug], doc_version(), path_component)
|
||||
return url
|
||||
|
||||
|
||||
class EventsTestMixin(TestCase):
|
||||
"""
|
||||
Helpers and setup for running tests that evaluate events emitted
|
||||
@@ -470,271 +347,6 @@ class EventsTestMixin(TestCase):
|
||||
self.event_collection = MongoClient(mongo_host)["test"]["events"]
|
||||
self.start_time = datetime.now()
|
||||
|
||||
def reset_event_tracking(self):
|
||||
"""Drop any events that have been collected thus far and start collecting again from scratch."""
|
||||
self.event_collection.drop()
|
||||
self.start_time = datetime.now()
|
||||
|
||||
@contextmanager
|
||||
def capture_events(self, event_filter=None, number_of_matches=1, captured_events=None):
|
||||
"""
|
||||
Context manager that captures all events emitted while executing a particular block.
|
||||
|
||||
All captured events are stored in the list referenced by `captured_events`. Note that this list is appended to
|
||||
*in place*. The events will be appended to the list in the order they are emitted.
|
||||
|
||||
The `event_filter` is expected to be a callable that allows you to filter the event stream and select particular
|
||||
events of interest. A dictionary `event_filter` is also supported, which simply indicates that the event should
|
||||
match that provided expectation.
|
||||
|
||||
`number_of_matches` tells this context manager when enough events have been found and it can move on. The
|
||||
context manager will not exit until this many events have passed the filter. If not enough events are found
|
||||
before a timeout expires, then this will raise a `BrokenPromise` error. Note that this simply states that
|
||||
*at least* this many events have been emitted, so `number_of_matches` is simply a lower bound for the size of
|
||||
`captured_events`.
|
||||
"""
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
yield
|
||||
|
||||
events = self.wait_for_events(
|
||||
start_time=start_time, event_filter=event_filter, number_of_matches=number_of_matches)
|
||||
|
||||
if captured_events is not None and hasattr(captured_events, 'append') and callable(captured_events.append):
|
||||
for event in events:
|
||||
captured_events.append(event)
|
||||
|
||||
@contextmanager
|
||||
def assert_events_match_during(self, event_filter=None, expected_events=None, in_order=True):
|
||||
"""
|
||||
Context manager that ensures that events matching the `event_filter` and `expected_events` are emitted.
|
||||
|
||||
This context manager will filter out the event stream using the `event_filter` and wait for
|
||||
`len(expected_events)` to match the filter.
|
||||
|
||||
It will then compare the events in order with their counterpart in `expected_events` to ensure they match the
|
||||
more detailed assertion.
|
||||
|
||||
Typically `event_filter` will be an `event_type` filter and the `expected_events` list will contain more
|
||||
detailed assertions.
|
||||
"""
|
||||
captured_events = []
|
||||
with self.capture_events(event_filter, len(expected_events), captured_events):
|
||||
yield
|
||||
|
||||
self.assert_events_match(expected_events, captured_events, in_order=in_order)
|
||||
|
||||
def wait_for_events(self, start_time=None, event_filter=None, number_of_matches=1, timeout=None):
|
||||
"""
|
||||
Wait for `number_of_matches` events to pass the `event_filter`.
|
||||
|
||||
By default, this will look at all events that have been emitted since the beginning of the setup of this mixin.
|
||||
A custom `start_time` can be specified which will limit the events searched to only those emitted after that
|
||||
time.
|
||||
|
||||
The `event_filter` is expected to be a callable that allows you to filter the event stream and select particular
|
||||
events of interest. A dictionary `event_filter` is also supported, which simply indicates that the event should
|
||||
match that provided expectation.
|
||||
|
||||
`number_of_matches` lets us know when enough events have been found and it can move on. The function will not
|
||||
return until this many events have passed the filter. If not enough events are found before a timeout expires,
|
||||
then this will raise a `BrokenPromise` error. Note that this simply states that *at least* this many events have
|
||||
been emitted, so `number_of_matches` is simply a lower bound for the size of `captured_events`.
|
||||
|
||||
Specifying a custom `timeout` can allow you to extend the default 30 second timeout if necessary.
|
||||
"""
|
||||
if start_time is None:
|
||||
start_time = self.start_time
|
||||
|
||||
if timeout is None:
|
||||
timeout = 30
|
||||
|
||||
def check_for_matching_events():
|
||||
"""Gather any events that have been emitted since `start_time`"""
|
||||
return self.matching_events_were_emitted(
|
||||
start_time=start_time,
|
||||
event_filter=event_filter,
|
||||
number_of_matches=number_of_matches
|
||||
)
|
||||
|
||||
return Promise(
|
||||
check_for_matching_events,
|
||||
# This is a bit of a hack, Promise calls str(description), so I set the description to an object with a
|
||||
# custom __str__ and have it do some intelligent stuff to generate a helpful error message.
|
||||
CollectedEventsDescription(
|
||||
u'Waiting for {number_of_matches} events to match the filter:\n{event_filter}'.format(
|
||||
number_of_matches=number_of_matches,
|
||||
event_filter=self.event_filter_to_descriptive_string(event_filter),
|
||||
),
|
||||
functools.partial(self.get_matching_events_from_time, start_time=start_time, event_filter={})
|
||||
),
|
||||
timeout=timeout
|
||||
).fulfill()
|
||||
|
||||
def matching_events_were_emitted(self, start_time=None, event_filter=None, number_of_matches=1):
|
||||
"""Return True if enough events have been emitted that pass the `event_filter` since `start_time`."""
|
||||
matching_events = self.get_matching_events_from_time(start_time=start_time, event_filter=event_filter)
|
||||
return len(matching_events) >= number_of_matches, matching_events
|
||||
|
||||
def get_matching_events_from_time(self, start_time=None, event_filter=None):
|
||||
"""
|
||||
Return a list of events that pass the `event_filter` and were emitted after `start_time`.
|
||||
|
||||
This function is used internally by most of the other assertions and convenience methods in this class.
|
||||
|
||||
The `event_filter` is expected to be a callable that allows you to filter the event stream and select particular
|
||||
events of interest. A dictionary `event_filter` is also supported, which simply indicates that the event should
|
||||
match that provided expectation.
|
||||
"""
|
||||
if start_time is None:
|
||||
start_time = self.start_time
|
||||
|
||||
if isinstance(event_filter, dict):
|
||||
event_filter = functools.partial(is_matching_event, event_filter)
|
||||
elif not callable(event_filter):
|
||||
raise ValueError(
|
||||
'event_filter must either be a dict or a callable function with as single "event" parameter that '
|
||||
'returns a boolean value.'
|
||||
)
|
||||
|
||||
matching_events = []
|
||||
cursor = self.event_collection.find(
|
||||
{
|
||||
"time": {
|
||||
"$gte": start_time
|
||||
}
|
||||
}
|
||||
).sort("time", ASCENDING)
|
||||
for event in cursor:
|
||||
matches = False
|
||||
try:
|
||||
# Mongo automatically assigns an _id to all events inserted into it. We strip it out here, since
|
||||
# we don't care about it.
|
||||
del event['_id']
|
||||
if event_filter is not None:
|
||||
# Typically we will be grabbing all events of a particular type, however, you can use arbitrary
|
||||
# logic to identify the events that are of interest.
|
||||
matches = event_filter(event)
|
||||
except AssertionError:
|
||||
# allow the filters to use "assert" to filter out events
|
||||
continue
|
||||
else:
|
||||
if matches is None or matches:
|
||||
matching_events.append(event)
|
||||
return matching_events
|
||||
|
||||
def assert_matching_events_were_emitted(self, start_time=None, event_filter=None, number_of_matches=1):
|
||||
"""Assert that at least `number_of_matches` events have passed the filter since `start_time`."""
|
||||
description = CollectedEventsDescription(
|
||||
'Not enough events match the filter:\n' + self.event_filter_to_descriptive_string(event_filter),
|
||||
functools.partial(self.get_matching_events_from_time, start_time=start_time, event_filter={})
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
self.matching_events_were_emitted(
|
||||
start_time=start_time, event_filter=event_filter, number_of_matches=number_of_matches
|
||||
),
|
||||
description
|
||||
)
|
||||
|
||||
def assert_no_matching_events_were_emitted(self, event_filter, start_time=None):
|
||||
"""Assert that no events have passed the filter since `start_time`."""
|
||||
matching_events = self.get_matching_events_from_time(start_time=start_time, event_filter=event_filter)
|
||||
|
||||
description = CollectedEventsDescription(
|
||||
'Events unexpected matched the filter:\n' + self.event_filter_to_descriptive_string(event_filter),
|
||||
lambda: matching_events
|
||||
)
|
||||
|
||||
self.assertEqual(len(matching_events), 0, description)
|
||||
|
||||
def assert_events_match(self, expected_events, actual_events, in_order=True):
|
||||
"""Assert that each actual event matches one of the expected events.
|
||||
|
||||
Args:
|
||||
expected_events (List): a list of dicts representing the expected events.
|
||||
actual_events (List): a list of dicts that were actually recorded.
|
||||
in_order (bool): if True then the events must be in the same order (defaults to True).
|
||||
"""
|
||||
if in_order:
|
||||
for expected_event, actual_event in zip(expected_events, actual_events):
|
||||
expected_field = (None if expected_event.get('event') is None else
|
||||
expected_event.get('event').get('field'))
|
||||
has_field = expected_field is not None
|
||||
actual_event_to_compare = (next(item for item in actual_events if item.get('event').get('field') ==
|
||||
expected_field)) if has_field else actual_event
|
||||
|
||||
assert_event_matches(expected_event, actual_event_to_compare, tolerate=EventMatchTolerates.lenient())
|
||||
else:
|
||||
for expected_event in expected_events:
|
||||
actual_event = next(event for event in actual_events if is_matching_event(expected_event, event))
|
||||
assert_event_matches(
|
||||
expected_event,
|
||||
actual_event or {},
|
||||
tolerate=EventMatchTolerates.lenient()
|
||||
)
|
||||
|
||||
def relative_path_to_absolute_uri(self, relative_path):
|
||||
"""Return an aboslute URI given a relative path taking into account the test context."""
|
||||
return six.moves.urllib.parse.urljoin(BASE_URL, relative_path)
|
||||
|
||||
def event_filter_to_descriptive_string(self, event_filter):
|
||||
"""Find the source code of the callable or pretty-print the dictionary"""
|
||||
message = ''
|
||||
if callable(event_filter):
|
||||
file_name = '(unknown)'
|
||||
try:
|
||||
file_name = inspect.getsourcefile(event_filter)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
list_of_source_lines, line_no = inspect.getsourcelines(event_filter)
|
||||
except IOError:
|
||||
pass
|
||||
else:
|
||||
message = '{file_name}:{line_no}\n{hr}\n{event_filter}\n{hr}'.format(
|
||||
event_filter=''.join(list_of_source_lines).rstrip(),
|
||||
file_name=file_name,
|
||||
line_no=line_no,
|
||||
hr='-' * 20,
|
||||
)
|
||||
|
||||
if not message:
|
||||
message = '{hr}\n{event_filter}\n{hr}'.format(
|
||||
event_filter=pprint.pformat(event_filter),
|
||||
hr='-' * 20,
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
class CollectedEventsDescription(object):
|
||||
"""
|
||||
Produce a clear error message when tests fail.
|
||||
|
||||
This class calls the provided `get_events_func` when converted to a string, and pretty prints the returned events.
|
||||
"""
|
||||
|
||||
def __init__(self, description, get_events_func):
|
||||
self.description = description
|
||||
self.get_events_func = get_events_func
|
||||
|
||||
def __str__(self):
|
||||
message_lines = [
|
||||
self.description,
|
||||
'Events:'
|
||||
]
|
||||
events = self.get_events_func()
|
||||
events.sort(key=operator.itemgetter('time'), reverse=True)
|
||||
for event in events[:MAX_EVENTS_IN_FAILURE_OUTPUT]:
|
||||
message_lines.append(pprint.pformat(event))
|
||||
if len(events) > MAX_EVENTS_IN_FAILURE_OUTPUT:
|
||||
message_lines.append(
|
||||
'Too many events to display, the remaining events were omitted. Run locally to diagnose.')
|
||||
|
||||
return '\n\n'.join(message_lines)
|
||||
|
||||
|
||||
class AcceptanceTest(WebAppTest):
|
||||
"""
|
||||
@@ -927,90 +539,3 @@ def create_user_partition_json(partition_id, name, description, groups, scheme="
|
||||
return UserPartition(
|
||||
partition_id, name, description, groups, MockScheme()
|
||||
).to_json()
|
||||
|
||||
|
||||
def assert_nav_help_link(test, page, href, signed_in=True, close_window=True):
|
||||
"""
|
||||
Asserts that help link in navigation bar is correct.
|
||||
|
||||
It first checks the url inside anchor DOM element and
|
||||
then clicks to ensure that help opens correctly.
|
||||
|
||||
Arguments:
|
||||
test (AcceptanceTest): Test object
|
||||
page (PageObject): Page object to perform tests on.
|
||||
href (str): The help link which we expect to see when it is opened.
|
||||
signed_in (bool): Specifies whether user is logged in or not. (It affects the css)
|
||||
close_window(bool): Close the newly-opened help window before continuing
|
||||
"""
|
||||
expected_link = {
|
||||
'href': href,
|
||||
'text': 'Help'
|
||||
}
|
||||
# Get actual anchor help element from the page.
|
||||
actual_link = page.get_nav_help_element_and_click_help(signed_in)
|
||||
# Assert that 'href' and text are the same as expected.
|
||||
assert_link(test, expected_link, actual_link)
|
||||
# Assert that opened link is correct
|
||||
assert_opened_help_link_is_correct(test, href)
|
||||
# Close the help window if not kept open intentionally
|
||||
if close_window:
|
||||
close_help_window(page)
|
||||
|
||||
|
||||
def assert_side_bar_help_link(test, page, href, help_text, as_list_item=False, index=-1, close_window=True):
|
||||
"""
|
||||
Asserts that help link in side bar is correct.
|
||||
|
||||
It first checks the url inside anchor DOM element and
|
||||
then clicks to ensure that help opens correctly.
|
||||
|
||||
Arguments:
|
||||
test (AcceptanceTest): Test object
|
||||
page (PageObject): Page object to perform tests on.
|
||||
href (str): The help link which we expect to see when it is opened.
|
||||
as_list_item (bool): Specifies whether help element is in one of the
|
||||
'li' inside a sidebar list DOM element.
|
||||
index (int): The index of element in case there are more than
|
||||
one matching elements.
|
||||
close_window(bool): Close the newly-opened help window before continuing
|
||||
"""
|
||||
expected_link = {
|
||||
'href': href,
|
||||
'text': help_text
|
||||
}
|
||||
# Get actual anchor help element from the page.
|
||||
actual_link = page.get_side_bar_help_element_and_click_help(as_list_item=as_list_item, index=index)
|
||||
# Assert that 'href' and text are the same as expected.
|
||||
assert_link(test, expected_link, actual_link)
|
||||
# Assert that opened link is correct
|
||||
assert_opened_help_link_is_correct(test, href)
|
||||
# Close the help window if not kept open intentionally
|
||||
if close_window:
|
||||
close_help_window(page)
|
||||
|
||||
|
||||
def close_help_window(page):
|
||||
"""
|
||||
Closes the help window
|
||||
Args:
|
||||
page (PageObject): Page object to perform tests on.
|
||||
"""
|
||||
browser_url = page.browser.current_url
|
||||
if browser_url.startswith('https://edx.readthedocs.io') or browser_url.startswith('http://edx.readthedocs.io'):
|
||||
page.browser.close() # close only the current window
|
||||
page.browser.switch_to_window(page.browser.window_handles[0])
|
||||
|
||||
|
||||
class TestWithSearchIndexMixin(object):
|
||||
""" Mixin encapsulating search index creation """
|
||||
TEST_INDEX_FILENAME = "test_root/index_file.dat"
|
||||
|
||||
def _create_search_index(self):
|
||||
""" Creates search index backing file """
|
||||
with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
|
||||
json.dump({}, index_file)
|
||||
|
||||
def _cleanup_index_file(self):
|
||||
""" Removes search index backing file """
|
||||
remove_file(self.TEST_INDEX_FILENAME)
|
||||
|
||||
@@ -4,16 +4,8 @@ End-to-end tests for the Account Settings page.
|
||||
"""
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
from unittest import skip
|
||||
|
||||
import six
|
||||
from bok_choy.page_object import XSS_INJECTION
|
||||
from pytz import timezone, utc
|
||||
|
||||
from common.test.acceptance.pages.common.auto_auth import FULL_NAME, AutoAuthPage
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.account_settings import AccountSettingsPage
|
||||
from common.test.acceptance.pages.lms.dashboard import DashboardPage
|
||||
from common.test.acceptance.tests.helpers import AcceptanceTest, EventsTestMixin
|
||||
|
||||
|
||||
@@ -56,457 +48,6 @@ class AccountSettingsTestMixin(EventsTestMixin, AcceptanceTest):
|
||||
user_id = auto_auth_page.get_user_id()
|
||||
return username, user_id
|
||||
|
||||
def settings_changed_event_filter(self, event):
|
||||
"""Filter out any events that are not "settings changed" events."""
|
||||
return event['event_type'] == self.USER_SETTINGS_CHANGED_EVENT_NAME
|
||||
|
||||
def expected_settings_changed_event(self, setting, old, new, table=None):
|
||||
"""A dictionary representing the expected fields in a "settings changed" event."""
|
||||
return {
|
||||
'username': self.username,
|
||||
'referer': self.get_settings_page_url(),
|
||||
'event': {
|
||||
'user_id': self.user_id,
|
||||
'setting': setting,
|
||||
'old': old,
|
||||
'new': new,
|
||||
'truncated': [],
|
||||
'table': table or 'auth_userprofile'
|
||||
}
|
||||
}
|
||||
|
||||
def settings_change_initiated_event_filter(self, event):
|
||||
"""Filter out any events that are not "settings change initiated" events."""
|
||||
return event['event_type'] == self.CHANGE_INITIATED_EVENT_NAME
|
||||
|
||||
def expected_settings_change_initiated_event(self, setting, old, new, username=None, user_id=None):
|
||||
"""A dictionary representing the expected fields in a "settings change initiated" event."""
|
||||
return {
|
||||
'username': username or self.username,
|
||||
'referer': self.get_settings_page_url(),
|
||||
'event': {
|
||||
'user_id': user_id or self.user_id,
|
||||
'setting': setting,
|
||||
'old': old,
|
||||
'new': new,
|
||||
}
|
||||
}
|
||||
|
||||
def get_settings_page_url(self):
|
||||
"""The absolute URL of the account settings page given the test context."""
|
||||
return self.relative_path_to_absolute_uri(self.ACCOUNT_SETTINGS_REFERER)
|
||||
|
||||
def assert_no_setting_changed_event(self):
|
||||
"""Assert no setting changed event has been emitted thus far."""
|
||||
self.assert_no_matching_events_were_emitted({'event_type': self.USER_SETTINGS_CHANGED_EVENT_NAME})
|
||||
|
||||
|
||||
class DashboardMenuTest(AccountSettingsTestMixin, AcceptanceTest):
|
||||
"""
|
||||
Tests that the dashboard menu works correctly with the account settings page.
|
||||
"""
|
||||
shard = 8
|
||||
|
||||
def test_link_on_dashboard_works(self):
|
||||
"""
|
||||
Scenario: Verify that the "Account" link works from the dashboard.
|
||||
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my dashboard
|
||||
And I click on "Account" in the top drop down
|
||||
Then I should see my account settings page
|
||||
"""
|
||||
self.log_in_as_unique_user()
|
||||
dashboard_page = DashboardPage(self.browser)
|
||||
dashboard_page.visit()
|
||||
dashboard_page.click_username_dropdown()
|
||||
self.assertIn('Account', dashboard_page.username_dropdown_link_text)
|
||||
dashboard_page.click_account_settings_link()
|
||||
|
||||
|
||||
class AccountSettingsPageTest(AccountSettingsTestMixin, AcceptanceTest):
|
||||
"""
|
||||
Tests that verify behaviour of the Account Settings page.
|
||||
"""
|
||||
SUCCESS_MESSAGE = 'Your changes have been saved.'
|
||||
shard = 8
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Initialize account and pages.
|
||||
"""
|
||||
super(AccountSettingsPageTest, self).setUp()
|
||||
self.full_name = FULL_NAME
|
||||
self.social_link = ''
|
||||
self.username, self.user_id = self.log_in_as_unique_user(full_name=self.full_name)
|
||||
self.visit_account_settings_page()
|
||||
|
||||
def test_page_view_event(self):
|
||||
"""
|
||||
Scenario: An event should be recorded when the "Account Settings"
|
||||
page is viewed.
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my account settings page
|
||||
Then a page view analytics event should be recorded
|
||||
"""
|
||||
|
||||
actual_events = self.wait_for_events(
|
||||
event_filter={'event_type': 'edx.user.settings.viewed'}, number_of_matches=1)
|
||||
self.assert_events_match(
|
||||
[
|
||||
{
|
||||
'event': {
|
||||
'user_id': self.user_id,
|
||||
'page': 'account',
|
||||
'visibility': None
|
||||
}
|
||||
}
|
||||
],
|
||||
actual_events
|
||||
)
|
||||
|
||||
def test_all_sections_and_fields_are_present(self):
|
||||
"""
|
||||
Scenario: Verify that all sections and fields are present on the page.
|
||||
"""
|
||||
expected_sections_structure = [
|
||||
{
|
||||
'title': 'Basic Account Information',
|
||||
'fields': [
|
||||
'Username',
|
||||
'Full Name',
|
||||
'Email Address (Sign In)',
|
||||
'Password',
|
||||
'Language',
|
||||
'Country or Region of Residence',
|
||||
'Time Zone',
|
||||
]
|
||||
},
|
||||
{
|
||||
'title': 'Additional Information',
|
||||
'fields': [
|
||||
'Education Completed',
|
||||
'Gender',
|
||||
'Year of Birth',
|
||||
'Preferred Language',
|
||||
]
|
||||
},
|
||||
{
|
||||
'title': 'Social Media Links',
|
||||
'fields': sorted([
|
||||
'Twitter Link',
|
||||
'Facebook Link',
|
||||
'LinkedIn Link',
|
||||
])
|
||||
},
|
||||
{
|
||||
'title': 'Delete My Account',
|
||||
'fields': []
|
||||
},
|
||||
]
|
||||
|
||||
sections_structure = self.account_settings_page.sections_structure()
|
||||
sections_structure[2]['fields'] = sorted(sections_structure[2]['fields'])
|
||||
self.assertEqual(sections_structure, expected_sections_structure)
|
||||
|
||||
def _test_readonly_field(self, field_id, title, value):
|
||||
"""
|
||||
Test behavior of a readonly field.
|
||||
"""
|
||||
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
|
||||
self.assertEqual(self.account_settings_page.value_for_readonly_field(field_id), value)
|
||||
|
||||
def _test_text_field(
|
||||
self, field_id, title, initial_value, new_invalid_value, new_valid_values, success_message=SUCCESS_MESSAGE,
|
||||
assert_after_reload=True
|
||||
):
|
||||
"""
|
||||
Test behaviour of a text field.
|
||||
"""
|
||||
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
|
||||
self.assertEqual(self.account_settings_page.value_for_text_field(field_id), initial_value)
|
||||
|
||||
self.assertEqual(
|
||||
self.account_settings_page.value_for_text_field(field_id, new_invalid_value), new_invalid_value
|
||||
)
|
||||
self.account_settings_page.wait_for_indicator(field_id, 'validation-error')
|
||||
self.browser.refresh()
|
||||
self.assertNotEqual(self.account_settings_page.value_for_text_field(field_id), new_invalid_value)
|
||||
|
||||
for new_value in new_valid_values:
|
||||
self.assertEqual(self.account_settings_page.value_for_text_field(field_id, new_value), new_value)
|
||||
self.account_settings_page.wait_for_message(field_id, success_message)
|
||||
if assert_after_reload:
|
||||
self.browser.refresh()
|
||||
self.assertEqual(self.account_settings_page.value_for_text_field(field_id), new_value)
|
||||
|
||||
def _test_dropdown_field(
|
||||
self,
|
||||
field_id,
|
||||
title,
|
||||
initial_value,
|
||||
new_values,
|
||||
success_message=SUCCESS_MESSAGE, # pylint: disable=unused-argument
|
||||
reloads_on_save=False
|
||||
):
|
||||
"""
|
||||
Test behaviour of a dropdown field.
|
||||
"""
|
||||
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
|
||||
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id, focus_out=True), initial_value)
|
||||
|
||||
for new_value in new_values:
|
||||
self.assertEqual(
|
||||
self.account_settings_page.value_for_dropdown_field(field_id, new_value, focus_out=True),
|
||||
new_value
|
||||
)
|
||||
# An XHR request is made when changing the field
|
||||
self.account_settings_page.wait_for_ajax()
|
||||
if reloads_on_save:
|
||||
self.account_settings_page.wait_for_loading_indicator()
|
||||
else:
|
||||
self.browser.refresh()
|
||||
self.account_settings_page.wait_for_page()
|
||||
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id, focus_out=True), new_value)
|
||||
|
||||
def _test_link_field(self, field_id, title, link_title, field_type, success_message):
|
||||
"""
|
||||
Test behaviour a link field.
|
||||
"""
|
||||
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
|
||||
self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title)
|
||||
self.account_settings_page.click_on_link_in_link_field(field_id, field_type=field_type)
|
||||
self.account_settings_page.wait_for_message(field_id, success_message)
|
||||
|
||||
def test_username_field(self):
|
||||
"""
|
||||
Test behaviour of "Username" field.
|
||||
"""
|
||||
self._test_readonly_field('username', 'Username', self.username)
|
||||
|
||||
def test_full_name_field(self):
|
||||
"""
|
||||
Test behaviour of "Full Name" field.
|
||||
"""
|
||||
self._test_text_field(
|
||||
u'name',
|
||||
u'Full Name',
|
||||
self.full_name,
|
||||
u' ',
|
||||
[u'<h1>another name<h1>', u'<script>'],
|
||||
'Full Name cannot contain the following characters: < >',
|
||||
False
|
||||
)
|
||||
|
||||
def test_email_field(self):
|
||||
"""
|
||||
Test behaviour of "Email" field.
|
||||
"""
|
||||
email = u"test@example.com"
|
||||
username, user_id = self.log_in_as_unique_user(email=email)
|
||||
self.visit_account_settings_page()
|
||||
self._test_text_field(
|
||||
u'email',
|
||||
u'Email Address (Sign In)',
|
||||
email,
|
||||
u'test@example.com' + XSS_INJECTION,
|
||||
[u'me@here.com', u'you@there.com'],
|
||||
success_message='Click the link in the message to update your email address.',
|
||||
assert_after_reload=False
|
||||
)
|
||||
|
||||
actual_events = self.wait_for_events(
|
||||
event_filter=self.settings_change_initiated_event_filter, number_of_matches=2)
|
||||
self.assert_events_match(
|
||||
[
|
||||
self.expected_settings_change_initiated_event(
|
||||
'email', email, 'me@here.com', username=username, user_id=user_id),
|
||||
# NOTE the first email change was never confirmed, so old has not changed.
|
||||
self.expected_settings_change_initiated_event(
|
||||
'email', email, 'you@there.com', username=username, user_id=user_id),
|
||||
],
|
||||
actual_events
|
||||
)
|
||||
# Email is not saved until user confirms, so no events should have been
|
||||
# emitted.
|
||||
self.assert_no_setting_changed_event()
|
||||
|
||||
def test_password_field(self):
|
||||
"""
|
||||
Test behaviour of "Password" field.
|
||||
"""
|
||||
self._test_link_field(
|
||||
u'password',
|
||||
u'Password',
|
||||
u'Reset Your Password',
|
||||
u'button',
|
||||
success_message='Click the link in the message to reset your password.',
|
||||
)
|
||||
|
||||
event_filter = self.expected_settings_change_initiated_event('password', None, None)
|
||||
self.wait_for_events(event_filter=event_filter, number_of_matches=1)
|
||||
# Like email, since the user has not confirmed their password change,
|
||||
# the field has not yet changed, so no events will have been emitted.
|
||||
self.assert_no_setting_changed_event()
|
||||
|
||||
@skip(
|
||||
'On bokchoy test servers, language changes take a few reloads to fully realize '
|
||||
'which means we can no longer reliably match the strings in the html in other tests.'
|
||||
)
|
||||
def test_language_field(self):
|
||||
"""
|
||||
Test behaviour of "Language" field.
|
||||
"""
|
||||
self._test_dropdown_field(
|
||||
u'pref-lang',
|
||||
u'Language',
|
||||
u'English',
|
||||
[u'Dummy Language (Esperanto)', u'English'],
|
||||
reloads_on_save=True,
|
||||
)
|
||||
|
||||
def test_country_field(self):
|
||||
"""
|
||||
Test behaviour of "Country or Region" field.
|
||||
"""
|
||||
self._test_dropdown_field(
|
||||
u'country',
|
||||
u'Country or Region of Residence',
|
||||
u'',
|
||||
[u'Pakistan', u'Palau'],
|
||||
)
|
||||
|
||||
def test_time_zone_field(self):
|
||||
"""
|
||||
Test behaviour of "Time Zone" field
|
||||
"""
|
||||
kiev_abbr, kiev_offset = self._get_time_zone_info('Europe/Kiev')
|
||||
pacific_abbr, pacific_offset = self._get_time_zone_info('US/Pacific')
|
||||
self._test_dropdown_field(
|
||||
u'time_zone',
|
||||
u'Time Zone',
|
||||
u'Default (Local Time Zone)',
|
||||
[
|
||||
u'Europe/Kiev ({abbr}, UTC{offset})'.format(abbr=kiev_abbr, offset=kiev_offset),
|
||||
u'US/Pacific ({abbr}, UTC{offset})'.format(abbr=pacific_abbr, offset=pacific_offset),
|
||||
],
|
||||
)
|
||||
|
||||
def _get_time_zone_info(self, time_zone_str):
|
||||
"""
|
||||
Helper that returns current time zone abbreviation and UTC offset
|
||||
and accounts for daylight savings time
|
||||
"""
|
||||
time_zone = datetime.now(utc).astimezone(timezone(time_zone_str))
|
||||
abbr = time_zone.strftime('%Z')
|
||||
offset = time_zone.strftime('%z')
|
||||
return abbr, offset
|
||||
|
||||
def test_social_links_field(self):
|
||||
"""
|
||||
Test behaviour of one of the social media links field.
|
||||
"""
|
||||
first_social_media_link = self.account_settings_page.get_social_first_element()
|
||||
|
||||
valid_value = 'https://www.twitter.com/edX'
|
||||
if 'face' in first_social_media_link.lower():
|
||||
valid_value = 'https://www.facebook.com/edX'
|
||||
elif 'linked' in first_social_media_link.lower():
|
||||
valid_value = 'https://www.linkedin.com/in/edX'
|
||||
|
||||
self._test_text_field(
|
||||
'social_links',
|
||||
first_social_media_link,
|
||||
self.social_link,
|
||||
'www.google.com/invalidlink)',
|
||||
[valid_value, self.social_link],
|
||||
)
|
||||
|
||||
def test_linked_accounts(self):
|
||||
"""
|
||||
Test that fields for third party auth providers exist.
|
||||
|
||||
Currently there is no way to test the whole authentication process
|
||||
because that would require accounts with the providers.
|
||||
"""
|
||||
providers = (
|
||||
['auth-oa2-facebook', 'Facebook', 'Link Your Account'],
|
||||
['auth-oa2-google-oauth2', 'Google', 'Link Your Account'],
|
||||
)
|
||||
# switch to "Linked Accounts" tab
|
||||
self.account_settings_page.switch_account_settings_tabs('accounts-tab')
|
||||
for field_id, title, link_title in providers:
|
||||
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
|
||||
self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title)
|
||||
|
||||
def test_order_history(self):
|
||||
"""
|
||||
Test that we can see orders on Order History tab.
|
||||
"""
|
||||
# switch to "Order History" tab
|
||||
self.account_settings_page.switch_account_settings_tabs('orders-tab')
|
||||
# verify that we are on correct tab
|
||||
self.assertTrue(self.account_settings_page.is_order_history_tab_visible)
|
||||
|
||||
expected_order_data_first_row = {
|
||||
'number': 'Order Number:\nEdx-123',
|
||||
'date': 'Date Placed:\nApr 21, 2016',
|
||||
'price': 'Cost:\n$100.00',
|
||||
}
|
||||
expected_order_data_second_row = {
|
||||
'number': 'Product Name:\nTest Course',
|
||||
'date': 'Date Placed:\nApr 21, 2016',
|
||||
'price': 'Cost:\n$100.00',
|
||||
}
|
||||
|
||||
for field_name, value in six.iteritems(expected_order_data_first_row):
|
||||
self.assertEqual(
|
||||
self.account_settings_page.get_value_of_order_history_row_item('order-Edx-123', field_name)[0], value
|
||||
)
|
||||
|
||||
for field_name, value in six.iteritems(expected_order_data_second_row):
|
||||
self.assertEqual(
|
||||
self.account_settings_page.get_value_of_order_history_row_item('order-Edx-123', field_name)[1], value
|
||||
)
|
||||
|
||||
self.assertTrue(self.account_settings_page.order_button_is_visible('order-Edx-123'))
|
||||
|
||||
|
||||
class AccountSettingsDeleteAccountTest(AccountSettingsTestMixin, AcceptanceTest):
|
||||
"""
|
||||
Tests for the account deletion workflow.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Initialize account and pages.
|
||||
"""
|
||||
super(AccountSettingsDeleteAccountTest, self).setUp()
|
||||
self.full_name = FULL_NAME
|
||||
self.social_link = ''
|
||||
self.password = 'password'
|
||||
self.username, self.user_id = self.log_in_as_unique_user(full_name=self.full_name, password=self.password)
|
||||
self.visit_account_settings_page(gdpr=True)
|
||||
|
||||
def test_button_visible(self):
|
||||
self.assertTrue(
|
||||
self.account_settings_page.is_delete_button_visible
|
||||
)
|
||||
|
||||
def test_delete_modal(self):
|
||||
self.account_settings_page.click_delete_button()
|
||||
self.assertTrue(
|
||||
self.account_settings_page.is_delete_modal_visible
|
||||
)
|
||||
self.assertFalse(
|
||||
self.account_settings_page.delete_confirm_button_enabled()
|
||||
)
|
||||
self.account_settings_page.fill_in_password_field(self.password)
|
||||
self.assertTrue(
|
||||
self.account_settings_page.delete_confirm_button_enabled()
|
||||
)
|
||||
|
||||
|
||||
class AccountSettingsA11yTest(AccountSettingsTestMixin, AcceptanceTest):
|
||||
"""
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""
|
||||
Acceptance tests for the certificate web view feature.
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.fixtures.certificates import CertificateConfigFixture
|
||||
from common.test.acceptance.fixtures.course import CourseFixture
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.certificate_page import CertificatePage
|
||||
from common.test.acceptance.tests.helpers import EventsTestMixin, UniqueCourseTest
|
||||
|
||||
|
||||
class CertificateWebViewTest(EventsTestMixin, UniqueCourseTest):
|
||||
"""
|
||||
Tests for verifying certificate web view features
|
||||
"""
|
||||
shard = 5
|
||||
|
||||
def setUp(self):
|
||||
super(CertificateWebViewTest, self).setUp()
|
||||
# set same course number as we have in fixture json
|
||||
self.course_info['number'] = "335535897951379478207964576572017930000"
|
||||
test_certificate_config = {
|
||||
'id': 1,
|
||||
'name': 'Certificate name',
|
||||
'description': 'Certificate description',
|
||||
'course_title': 'Course title override',
|
||||
'signatories': [],
|
||||
'version': 1,
|
||||
'is_active': True,
|
||||
}
|
||||
course_settings = {'certificates': test_certificate_config}
|
||||
self.course_fixture = CourseFixture(
|
||||
self.course_info["org"],
|
||||
self.course_info["number"],
|
||||
self.course_info["run"],
|
||||
self.course_info["display_name"],
|
||||
settings=course_settings
|
||||
)
|
||||
self.course_fixture.add_advanced_settings({
|
||||
"cert_html_view_enabled": {"value": "true"},
|
||||
"certificates_display_behavior": {"value": "early_with_info"},
|
||||
})
|
||||
self.course_fixture.install()
|
||||
self.user_id = "99" # we have created a user with this id in fixture
|
||||
self.cert_fixture = CertificateConfigFixture(self.course_id, test_certificate_config)
|
||||
|
||||
# Load certificate web view page for use by the tests
|
||||
self.certificate_page = CertificatePage(self.browser, self.user_id, self.course_id)
|
||||
|
||||
def log_in_as_unique_user(self):
|
||||
"""
|
||||
Log in as a valid lms user.
|
||||
"""
|
||||
AutoAuthPage(
|
||||
self.browser,
|
||||
username="testcert",
|
||||
email="cert@example.com",
|
||||
password="testuser",
|
||||
course_id=self.course_id
|
||||
).visit()
|
||||
|
||||
def test_page_has_accomplishments_banner(self):
|
||||
"""
|
||||
Scenario: User accomplishment banner should be present if logged in user is the one who is awarded
|
||||
the certificate
|
||||
Given there is a course with certificate configuration
|
||||
And I have passed the course and certificate is generated
|
||||
When I view the certificate web view page
|
||||
Then I should see the accomplishment banner. banner should have linked-in and facebook share buttons
|
||||
And When I click on `Add to Profile` button `edx.certificate.shared` event should be emitted
|
||||
"""
|
||||
self.cert_fixture.install()
|
||||
self.log_in_as_unique_user()
|
||||
self.certificate_page.visit()
|
||||
self.assertTrue(self.certificate_page.accomplishment_banner.visible)
|
||||
self.assertTrue(self.certificate_page.add_to_linkedin_profile_button.visible)
|
||||
self.assertTrue(self.certificate_page.add_to_facebook_profile_button.visible)
|
||||
self.certificate_page.add_to_linkedin_profile_button.click()
|
||||
actual_events = self.wait_for_events(
|
||||
event_filter={'event_type': 'edx.certificate.shared'},
|
||||
number_of_matches=1
|
||||
)
|
||||
expected_events = [
|
||||
{
|
||||
'event': {
|
||||
'user_id': self.user_id,
|
||||
'course_id': self.course_id
|
||||
}
|
||||
}
|
||||
]
|
||||
self.assert_events_match(expected_events, actual_events)
|
||||
@@ -4,14 +4,10 @@ End-to-end tests for Student's Profile Page.
|
||||
"""
|
||||
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
|
||||
import six
|
||||
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.common.logout import LogoutPage
|
||||
from common.test.acceptance.pages.lms.account_settings import AccountSettingsPage
|
||||
from common.test.acceptance.pages.lms.learner_profile import LearnerProfilePage
|
||||
from common.test.acceptance.tests.helpers import AcceptanceTest, EventsTestMixin
|
||||
|
||||
@@ -86,81 +82,6 @@ class LearnerProfileTestMixin(EventsTestMixin):
|
||||
|
||||
return profile_page
|
||||
|
||||
def set_birth_year(self, birth_year):
|
||||
"""
|
||||
Set birth year for the current user to the specified value.
|
||||
"""
|
||||
account_settings_page = AccountSettingsPage(self.browser)
|
||||
account_settings_page.visit()
|
||||
account_settings_page.wait_for_page()
|
||||
self.assertEqual(
|
||||
account_settings_page.value_for_dropdown_field('year_of_birth', str(birth_year), focus_out=True),
|
||||
str(birth_year)
|
||||
)
|
||||
|
||||
def verify_profile_page_is_public(self, profile_page, is_editable=True):
|
||||
"""
|
||||
Verify that the profile page is currently public.
|
||||
"""
|
||||
self.assertEqual(profile_page.visible_fields, self.PUBLIC_PROFILE_FIELDS)
|
||||
if is_editable:
|
||||
self.assertTrue(profile_page.privacy_field_visible)
|
||||
self.assertEqual(profile_page.editable_fields, self.PUBLIC_PROFILE_EDITABLE_FIELDS)
|
||||
else:
|
||||
self.assertEqual(profile_page.editable_fields, [])
|
||||
|
||||
def verify_profile_page_is_private(self, profile_page, is_editable=True):
|
||||
"""
|
||||
Verify that the profile page is currently private.
|
||||
"""
|
||||
if is_editable:
|
||||
self.assertTrue(profile_page.privacy_field_visible)
|
||||
self.assertEqual(profile_page.visible_fields, self.PRIVATE_PROFILE_FIELDS)
|
||||
|
||||
def verify_profile_page_view_event(self, requesting_username, profile_user_id, visibility=None):
|
||||
"""
|
||||
Verifies that the correct view event was captured for the profile page.
|
||||
"""
|
||||
|
||||
actual_events = self.wait_for_events(
|
||||
start_time=self.start_time,
|
||||
event_filter={'event_type': 'edx.user.settings.viewed', 'username': requesting_username},
|
||||
number_of_matches=1)
|
||||
self.assert_events_match(
|
||||
[
|
||||
{
|
||||
'username': requesting_username,
|
||||
'event': {
|
||||
'user_id': int(profile_user_id),
|
||||
'page': 'profile',
|
||||
'visibility': six.text_type(visibility)
|
||||
}
|
||||
}
|
||||
],
|
||||
actual_events
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def verify_pref_change_event_during(self, username, user_id, setting, **kwargs):
|
||||
"""Assert that a single setting changed event is emitted for the user_api_userpreference table."""
|
||||
expected_event = {
|
||||
'username': username,
|
||||
'event': {
|
||||
'setting': setting,
|
||||
'user_id': int(user_id),
|
||||
'table': 'user_api_userpreference',
|
||||
'truncated': []
|
||||
}
|
||||
}
|
||||
expected_event['event'].update(kwargs)
|
||||
|
||||
event_filter = {
|
||||
'event_type': self.USER_SETTINGS_CHANGED_EVENT_NAME,
|
||||
'username': username,
|
||||
}
|
||||
with self.assert_events_match_during(event_filter=event_filter, expected_events=[expected_event]):
|
||||
yield
|
||||
|
||||
def initialize_different_user(self, privacy=None, birth_year=None):
|
||||
"""
|
||||
Initialize the profile page for a different test user
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
End-to-end tests for LibraryContent block in LMS
|
||||
"""
|
||||
|
||||
|
||||
import textwrap
|
||||
|
||||
import ddt
|
||||
import six
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.fixtures.library import LibraryFixture
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.common.logout import LogoutPage
|
||||
from common.test.acceptance.pages.lms.course_home import CourseHomePage
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.pages.lms.library import LibraryContentXBlockWrapper
|
||||
from common.test.acceptance.pages.studio.library import StudioLibraryContainerXBlockWrapper, StudioLibraryContentEditor
|
||||
from common.test.acceptance.pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
|
||||
from common.test.acceptance.tests.helpers import TestWithSearchIndexMixin, UniqueCourseTest
|
||||
|
||||
SECTION_NAME = 'Test Section'
|
||||
SUBSECTION_NAME = 'Test Subsection'
|
||||
UNIT_NAME = 'Test Unit'
|
||||
|
||||
|
||||
class LibraryContentTestBase(UniqueCourseTest):
|
||||
""" Base class for library content block tests """
|
||||
USERNAME = "STUDENT_TESTER"
|
||||
EMAIL = "student101@example.com"
|
||||
|
||||
STAFF_USERNAME = "STAFF_TESTER"
|
||||
STAFF_EMAIL = "staff101@example.com"
|
||||
shard = 10
|
||||
|
||||
def populate_library_fixture(self, library_fixture):
|
||||
"""
|
||||
To be overwritten by subclassed tests. Used to install a library to
|
||||
run tests on.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up library, course and library content XBlock
|
||||
"""
|
||||
super(LibraryContentTestBase, self).setUp()
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
|
||||
self.studio_course_outline = StudioCourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
self.library_fixture = LibraryFixture('test_org', self.unique_id, u'Test Library {}'.format(self.unique_id))
|
||||
self.populate_library_fixture(self.library_fixture)
|
||||
|
||||
self.library_fixture.install()
|
||||
self.library_info = self.library_fixture.library_info
|
||||
self.library_key = self.library_fixture.library_key
|
||||
|
||||
# Install a course with library content xblock
|
||||
self.course_fixture = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
|
||||
library_content_metadata = {
|
||||
'source_library_id': six.text_type(self.library_key),
|
||||
'mode': 'random',
|
||||
'max_count': 1,
|
||||
}
|
||||
|
||||
self.lib_block = XBlockFixtureDesc('library_content', "Library Content", metadata=library_content_metadata)
|
||||
|
||||
self.course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', SECTION_NAME).add_children(
|
||||
XBlockFixtureDesc('sequential', SUBSECTION_NAME).add_children(
|
||||
XBlockFixtureDesc('vertical', UNIT_NAME).add_children(
|
||||
self.lib_block
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
self.course_fixture.install()
|
||||
|
||||
def _change_library_content_settings(self, count=1, capa_type=None):
|
||||
"""
|
||||
Performs library block refresh in Studio, configuring it to show {count} children
|
||||
"""
|
||||
unit_page = self._go_to_unit_page(True)
|
||||
library_container_block = StudioLibraryContainerXBlockWrapper.from_xblock_wrapper(unit_page.xblocks[1])
|
||||
library_container_block.edit()
|
||||
editor = StudioLibraryContentEditor(self.browser, library_container_block.locator)
|
||||
editor.count = count
|
||||
if capa_type is not None:
|
||||
editor.capa_type = capa_type
|
||||
editor.save()
|
||||
self._go_to_unit_page(change_login=False)
|
||||
unit_page.wait_for_page()
|
||||
unit_page.publish()
|
||||
self.assertIn("Published and Live", unit_page.publish_title)
|
||||
|
||||
@property
|
||||
def library_xblocks_texts(self):
|
||||
"""
|
||||
Gets texts of all xblocks in library
|
||||
"""
|
||||
return frozenset(child.data for child in self.library_fixture.children)
|
||||
|
||||
def _go_to_unit_page(self, change_login=True):
|
||||
"""
|
||||
Open unit page in Studio
|
||||
"""
|
||||
if change_login:
|
||||
LogoutPage(self.browser).visit()
|
||||
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
|
||||
self.studio_course_outline.visit()
|
||||
|
||||
subsection = self.studio_course_outline.section(SECTION_NAME).subsection(SUBSECTION_NAME)
|
||||
return subsection.expand_subsection().unit(UNIT_NAME).go_to()
|
||||
|
||||
def _goto_library_block_page(self, block_id=None):
|
||||
"""
|
||||
Open library page in LMS
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
paragraphs = self.courseware_page.q(css='.course-content p').results
|
||||
if not paragraphs:
|
||||
course_home_page = CourseHomePage(self.browser, self.course_id)
|
||||
course_home_page.visit()
|
||||
course_home_page.outline.go_to_section_by_index(0, 0)
|
||||
block_id = block_id if block_id is not None else self.lib_block.locator
|
||||
#pylint: disable=attribute-defined-outside-init
|
||||
self.library_content_page = LibraryContentXBlockWrapper(self.browser, block_id)
|
||||
self.library_content_page.wait_for_page()
|
||||
|
||||
def _auto_auth(self, username, email, staff):
|
||||
"""
|
||||
Logout and login with given credentials.
|
||||
"""
|
||||
AutoAuthPage(self.browser, username=username, email=email,
|
||||
course_id=self.course_id, staff=staff).visit()
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class LibraryContentTest(LibraryContentTestBase):
|
||||
"""
|
||||
Test courseware.
|
||||
"""
|
||||
shard = 10
|
||||
|
||||
def populate_library_fixture(self, library_fixture):
|
||||
"""
|
||||
Populates library fixture with XBlock Fixtures
|
||||
"""
|
||||
library_fixture.add_children(
|
||||
XBlockFixtureDesc("html", "Html1", data='html1'),
|
||||
XBlockFixtureDesc("html", "Html2", data='html2'),
|
||||
XBlockFixtureDesc("html", "Html3", data='html3'),
|
||||
XBlockFixtureDesc("html", "Html4", data='html4'),
|
||||
)
|
||||
|
||||
@ddt.data(2, 3, 4)
|
||||
def test_shows_random_xblocks_from_configured(self, count):
|
||||
"""
|
||||
Scenario: Ensures that library content shows {count} random xblocks from library in LMS
|
||||
Given I have a library, a course and a LibraryContent block in that course
|
||||
When I go to studio unit page for library content xblock as staff
|
||||
And I set library content xblock to display {count} random children
|
||||
And I refresh library content xblock and pulbish unit
|
||||
When I go to LMS courseware page for library content xblock as student
|
||||
Then I can see {count} random xblocks from the library
|
||||
"""
|
||||
self._change_library_content_settings(count=count)
|
||||
self._auto_auth(self.USERNAME, self.EMAIL, False)
|
||||
self._goto_library_block_page()
|
||||
children_contents = self.library_content_page.children_contents
|
||||
self.assertEqual(len(children_contents), count)
|
||||
self.assertLessEqual(children_contents, self.library_xblocks_texts)
|
||||
|
||||
def test_shows_all_if_max_set_to_greater_value(self):
|
||||
"""
|
||||
Scenario: Ensures that library content shows {count} random xblocks from library in LMS
|
||||
Given I have a library, a course and a LibraryContent block in that course
|
||||
When I go to studio unit page for library content xblock as staff
|
||||
And I set library content xblock to display more children than library have
|
||||
And I refresh library content xblock and pulbish unit
|
||||
When I go to LMS courseware page for library content xblock as student
|
||||
Then I can see all xblocks from the library
|
||||
"""
|
||||
self._change_library_content_settings(count=10)
|
||||
self._auto_auth(self.USERNAME, self.EMAIL, False)
|
||||
self._goto_library_block_page()
|
||||
children_contents = self.library_content_page.children_contents
|
||||
self.assertEqual(len(children_contents), 4)
|
||||
self.assertEqual(children_contents, self.library_xblocks_texts)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase, TestWithSearchIndexMixin):
|
||||
"""
|
||||
Test Library Content block in LMS
|
||||
"""
|
||||
shard = 10
|
||||
|
||||
def setUp(self):
|
||||
""" SetUp method """
|
||||
self._create_search_index()
|
||||
super(StudioLibraryContainerCapaFilterTest, self).setUp()
|
||||
|
||||
def tearDown(self):
|
||||
self._cleanup_index_file()
|
||||
super(StudioLibraryContainerCapaFilterTest, self).tearDown()
|
||||
|
||||
def _get_problem_choice_group_text(self, name, items):
|
||||
""" Generates Choice Group CAPA problem XML """
|
||||
items_text = "\n".join([
|
||||
u"<choice correct='{correct}'>{item}</choice>".format(correct=correct, item=item)
|
||||
for item, correct in items
|
||||
])
|
||||
|
||||
return textwrap.dedent(u"""
|
||||
<problem>
|
||||
<p>{name}</p>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup label="{name}" type="MultipleChoice">{items}</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>""").format(name=name, items=items_text)
|
||||
|
||||
def _get_problem_select_text(self, name, items, correct):
|
||||
""" Generates Select Option CAPA problem XML """
|
||||
items_text = ",".join(["'{0}'".format(item) for item in items])
|
||||
|
||||
return textwrap.dedent(u"""
|
||||
<problem>
|
||||
<p>{name}</p>
|
||||
<optionresponse>
|
||||
<optioninput label="{name}" options="({options})" correct="{correct}"></optioninput>
|
||||
</optionresponse>
|
||||
</problem>""").format(name=name, options=items_text, correct=correct)
|
||||
|
||||
def populate_library_fixture(self, library_fixture):
|
||||
"""
|
||||
Populates library fixture with XBlock Fixtures
|
||||
"""
|
||||
items = (
|
||||
XBlockFixtureDesc(
|
||||
"problem", "Problem Choice Group 1",
|
||||
data=self._get_problem_choice_group_text("Problem Choice Group 1 Text", [("1", False), ('2', True)])
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
"problem", "Problem Choice Group 2",
|
||||
data=self._get_problem_choice_group_text("Problem Choice Group 2 Text", [("Q", True), ('W', False)])
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
"problem", "Problem Select 1",
|
||||
data=self._get_problem_select_text("Problem Select 1 Text", ["Option 1", "Option 2"], "Option 1")
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
"problem", "Problem Select 2",
|
||||
data=self._get_problem_select_text("Problem Select 2 Text", ["Option 3", "Option 4"], "Option 4")
|
||||
),
|
||||
)
|
||||
library_fixture.add_children(*items)
|
||||
|
||||
@property
|
||||
def _problem_headers(self):
|
||||
""" Expected XBLock headers according to populate_library_fixture """
|
||||
return frozenset(child.display_name for child in self.library_fixture.children)
|
||||
|
||||
def _set_library_content_settings(self, count=1, capa_type="Any Type"):
|
||||
"""
|
||||
Sets library content XBlock parameters, saves, publishes unit, goes to LMS unit page and
|
||||
gets children XBlock headers to assert against them
|
||||
"""
|
||||
self._change_library_content_settings(count=count, capa_type=capa_type)
|
||||
self._auto_auth(self.USERNAME, self.EMAIL, False)
|
||||
self._goto_library_block_page()
|
||||
return self.library_content_page.children_headers
|
||||
@@ -4,18 +4,8 @@ End-to-end tests for the LMS.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from textwrap import dedent
|
||||
|
||||
import pytz
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.fixtures.course import CourseFixture
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.common.logout import LogoutPage
|
||||
from common.test.acceptance.pages.lms import BASE_URL
|
||||
from common.test.acceptance.pages.lms.account_settings import AccountSettingsPage
|
||||
from common.test.acceptance.pages.lms.course_about import CourseAboutPage
|
||||
from common.test.acceptance.pages.lms.course_home import CourseHomePage
|
||||
from common.test.acceptance.pages.lms.course_wiki import (
|
||||
CourseWikiChildrenPage,
|
||||
@@ -23,375 +13,13 @@ from common.test.acceptance.pages.lms.course_wiki import (
|
||||
CourseWikiHistoryPage,
|
||||
CourseWikiPage
|
||||
)
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.pages.lms.create_mode import ModeCreationPage
|
||||
from common.test.acceptance.pages.lms.dashboard import DashboardPage
|
||||
from common.test.acceptance.pages.lms.discovery import CourseDiscoveryPage
|
||||
from common.test.acceptance.pages.lms.login_and_register import CombinedLoginAndRegisterPage, ResetPasswordPage
|
||||
from common.test.acceptance.pages.lms.problem import ProblemPage
|
||||
from common.test.acceptance.pages.lms.tab_nav import TabNavPage
|
||||
from common.test.acceptance.tests.helpers import (
|
||||
EventsTestMixin,
|
||||
UniqueCourseTest,
|
||||
remove_file,
|
||||
)
|
||||
from openedx.core.lib.tests import attr
|
||||
|
||||
|
||||
@attr(shard=19)
|
||||
class ForgotPasswordPageTest(UniqueCourseTest):
|
||||
"""
|
||||
Test that forgot password forms is rendered if url contains 'forgot-password-modal'
|
||||
in hash.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
""" Initialize the page object """
|
||||
super(ForgotPasswordPageTest, self).setUp()
|
||||
self.user_info = self._create_user()
|
||||
self.reset_password_page = ResetPasswordPage(self.browser)
|
||||
|
||||
def _create_user(self):
|
||||
"""
|
||||
Create a unique user
|
||||
"""
|
||||
auto_auth = AutoAuthPage(self.browser).visit()
|
||||
user_info = auto_auth.user_info
|
||||
LogoutPage(self.browser).visit()
|
||||
return user_info
|
||||
|
||||
def test_reset_password_form_visibility(self):
|
||||
# Navigate to the password reset page
|
||||
self.reset_password_page.visit()
|
||||
|
||||
# Expect that reset password form is visible on the page
|
||||
self.assertTrue(self.reset_password_page.is_form_visible())
|
||||
|
||||
def test_reset_password_confirmation_box_visibility(self):
|
||||
# Navigate to the password reset page
|
||||
self.reset_password_page.visit()
|
||||
|
||||
# Navigate to the password reset form and try to submit it
|
||||
self.reset_password_page.fill_password_reset_form(self.user_info['email'])
|
||||
|
||||
self.reset_password_page.is_success_visible(".submission-success")
|
||||
|
||||
# Expect that we're shown a success message
|
||||
self.assertIn("Check Your Email", self.reset_password_page.get_success_message())
|
||||
|
||||
|
||||
@attr(shard=19)
|
||||
class LoginFromCombinedPageTest(UniqueCourseTest):
|
||||
"""Test that we can log in using the combined login/registration page.
|
||||
|
||||
Also test that we can request a password reset from the combined
|
||||
login/registration page.
|
||||
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Initialize the page objects and create a test course. """
|
||||
super(LoginFromCombinedPageTest, self).setUp()
|
||||
self.login_page = CombinedLoginAndRegisterPage(
|
||||
self.browser,
|
||||
start_page="login",
|
||||
course_id=self.course_id
|
||||
)
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
|
||||
# Create a course to enroll in
|
||||
CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
).install()
|
||||
|
||||
def test_login_success(self):
|
||||
# Create a user account
|
||||
email, password = self._create_unique_user()
|
||||
|
||||
# Navigate to the login page and try to log in
|
||||
self.login_page.visit().login(email=email, password=password)
|
||||
|
||||
# Expect that we reach the dashboard and we're auto-enrolled in the course
|
||||
course_names = self.dashboard_page.wait_for_page().available_courses
|
||||
self.assertIn(self.course_info["display_name"], course_names)
|
||||
|
||||
def test_login_failure(self):
|
||||
# Navigate to the login page
|
||||
self.login_page.visit()
|
||||
|
||||
# User account does not exist
|
||||
self.login_page.login(email="nobody@nowhere.com", password="password")
|
||||
|
||||
# Verify that an error is displayed
|
||||
self.assertIn("Email or password is incorrect.", self.login_page.wait_for_errors())
|
||||
|
||||
def test_toggle_to_register_form(self):
|
||||
self.login_page.visit().toggle_form()
|
||||
self.assertEqual(self.login_page.current_form, "register")
|
||||
|
||||
def test_password_reset_success(self):
|
||||
# Create a user account
|
||||
email, password = self._create_unique_user() # pylint: disable=unused-variable
|
||||
|
||||
# Navigate to the password reset form and try to submit it
|
||||
self.login_page.visit().password_reset(email=email)
|
||||
|
||||
# Expect that we're shown a success message
|
||||
self.assertIn("Check Your Email", self.login_page.wait_for_success())
|
||||
|
||||
def test_password_reset_no_user(self):
|
||||
# Navigate to the password reset form
|
||||
self.login_page.visit()
|
||||
|
||||
# User account does not exist
|
||||
self.login_page.password_reset(email="nobody@nowhere.com")
|
||||
|
||||
# Expect that we're shown a success message
|
||||
self.assertIn("Check Your Email", self.login_page.wait_for_success())
|
||||
|
||||
def test_third_party_login(self):
|
||||
"""
|
||||
Test that we can login using third party credentials, and that the
|
||||
third party account gets linked to the edX account.
|
||||
"""
|
||||
# Create a user account
|
||||
email, password = self._create_unique_user()
|
||||
|
||||
# Navigate to the login page
|
||||
self.login_page.visit()
|
||||
# Baseline screen-shots are different for chrome and firefox.
|
||||
#self.assertScreenshot('#login .login-providers', 'login-providers-{}'.format(self.browser.name), .25)
|
||||
#The line above is commented out temporarily see SOL-1937
|
||||
|
||||
# Try to log in using "Dummy" provider
|
||||
self.login_page.click_third_party_dummy_provider()
|
||||
|
||||
# The user will be redirected somewhere and then back to the login page:
|
||||
msg_text = self.login_page.wait_for_auth_status_message()
|
||||
self.assertIn("You have successfully signed into Dummy", msg_text)
|
||||
self.assertIn(
|
||||
u"To link your accounts, sign in now using your édX password",
|
||||
msg_text
|
||||
)
|
||||
|
||||
# Now login with username and password:
|
||||
self.login_page.login(email=email, password=password)
|
||||
|
||||
# Expect that we reach the dashboard and we're auto-enrolled in the course
|
||||
course_names = self.dashboard_page.wait_for_page().available_courses
|
||||
self.assertIn(self.course_info["display_name"], course_names)
|
||||
|
||||
try:
|
||||
# Now logout and check that we can log back in instantly (because the account is linked):
|
||||
LogoutPage(self.browser).visit()
|
||||
|
||||
self.login_page.visit()
|
||||
self.login_page.click_third_party_dummy_provider()
|
||||
|
||||
self.dashboard_page.wait_for_page()
|
||||
finally:
|
||||
self._unlink_dummy_account()
|
||||
|
||||
def test_hinted_login(self):
|
||||
""" Test the login page when coming from course URL that specified which third party provider to use """
|
||||
# Create a user account and link it to third party auth with the dummy provider:
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
self._link_dummy_account()
|
||||
try:
|
||||
LogoutPage(self.browser).visit()
|
||||
|
||||
# When not logged in, try to load a course URL that includes the provider hint ?tpa_hint=...
|
||||
course_page = CoursewarePage(self.browser, self.course_id)
|
||||
self.browser.get(course_page.url + '?tpa_hint=oa2-dummy')
|
||||
|
||||
# We should now be redirected to the login page
|
||||
self.login_page.wait_for_page()
|
||||
self.assertIn(
|
||||
"Would you like to sign in using your Dummy credentials?",
|
||||
self.login_page.hinted_login_prompt
|
||||
)
|
||||
|
||||
# Baseline screen-shots are different for chrome and firefox.
|
||||
#self.assertScreenshot('#hinted-login-form', 'hinted-login-{}'.format(self.browser.name), .25)
|
||||
#The line above is commented out temporarily see SOL-1937
|
||||
self.login_page.click_third_party_dummy_provider()
|
||||
|
||||
# We should now be redirected to the course page
|
||||
course_page.wait_for_page()
|
||||
finally:
|
||||
self._unlink_dummy_account()
|
||||
|
||||
def _link_dummy_account(self):
|
||||
""" Go to Account Settings page and link the user's account to the Dummy provider """
|
||||
account_settings = AccountSettingsPage(self.browser).visit()
|
||||
# switch to "Linked Accounts" tab
|
||||
account_settings.switch_account_settings_tabs('accounts-tab')
|
||||
|
||||
field_id = "auth-oa2-dummy"
|
||||
account_settings.wait_for_field(field_id)
|
||||
self.assertEqual("Link Your Account", account_settings.link_title_for_link_field(field_id))
|
||||
account_settings.click_on_link_in_link_field(field_id)
|
||||
|
||||
# make sure we are on "Linked Accounts" tab after the account settings
|
||||
# page is reloaded
|
||||
account_settings.switch_account_settings_tabs('accounts-tab')
|
||||
account_settings.wait_for_link_title_for_link_field(field_id, "Unlink This Account")
|
||||
|
||||
def _unlink_dummy_account(self):
|
||||
""" Verify that the 'Dummy' third party auth provider is linked, then unlink it """
|
||||
# This must be done after linking the account, or we'll get cross-test side effects
|
||||
account_settings = AccountSettingsPage(self.browser).visit()
|
||||
# switch to "Linked Accounts" tab
|
||||
account_settings.switch_account_settings_tabs('accounts-tab')
|
||||
|
||||
field_id = "auth-oa2-dummy"
|
||||
account_settings.wait_for_field(field_id)
|
||||
self.assertEqual("Unlink This Account", account_settings.link_title_for_link_field(field_id))
|
||||
account_settings.click_on_link_in_link_field(field_id)
|
||||
account_settings.wait_for_message(field_id, "Successfully unlinked")
|
||||
|
||||
def _create_unique_user(self):
|
||||
"""
|
||||
Create a new user with a unique name and email.
|
||||
"""
|
||||
username = u"test_{uuid}".format(uuid=self.unique_id[0:6])
|
||||
email = u"{user}@example.com".format(user=username)
|
||||
password = "password"
|
||||
|
||||
# Create the user (automatically logs us in)
|
||||
AutoAuthPage(
|
||||
self.browser,
|
||||
username=username,
|
||||
email=email,
|
||||
password=password
|
||||
).visit()
|
||||
|
||||
# Log out
|
||||
LogoutPage(self.browser).visit()
|
||||
|
||||
return (email, password)
|
||||
|
||||
|
||||
@attr(shard=19)
|
||||
class RegisterFromCombinedPageTest(UniqueCourseTest):
|
||||
"""Test that we can register a new user from the combined login/registration page. """
|
||||
|
||||
def setUp(self):
|
||||
"""Initialize the page objects and create a test course. """
|
||||
super(RegisterFromCombinedPageTest, self).setUp()
|
||||
self.register_page = CombinedLoginAndRegisterPage(
|
||||
self.browser,
|
||||
start_page="register",
|
||||
course_id=self.course_id
|
||||
)
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
|
||||
# Create a course to enroll in
|
||||
CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
).install()
|
||||
|
||||
def test_register_success(self):
|
||||
# Navigate to the registration page
|
||||
self.register_page.visit()
|
||||
|
||||
# Fill in the form and submit it
|
||||
username = u"test_{uuid}".format(uuid=self.unique_id[0:6])
|
||||
email = u"{user}@example.com".format(user=username)
|
||||
self.register_page.register(
|
||||
email=email,
|
||||
password="password",
|
||||
username=username,
|
||||
full_name="Test User",
|
||||
country="US",
|
||||
favorite_movie="Mad Max: Fury Road"
|
||||
)
|
||||
|
||||
# Expect that we reach the dashboard and we're auto-enrolled in the course
|
||||
course_names = self.dashboard_page.wait_for_page().available_courses
|
||||
self.assertIn(self.course_info["display_name"], course_names)
|
||||
|
||||
def test_register_failure(self):
|
||||
# Navigate to the registration page
|
||||
self.register_page.visit()
|
||||
|
||||
# Enter a blank for the username field, which is required
|
||||
# Don't agree to the terms of service / honor code.
|
||||
# Don't specify a country code, which is required.
|
||||
# Don't specify a favorite movie.
|
||||
username = u"test_{uuid}".format(uuid=self.unique_id[0:6])
|
||||
email = u"{user}@example.com".format(user=username)
|
||||
self.register_page.register(
|
||||
email=email,
|
||||
password="password",
|
||||
username="",
|
||||
full_name="Test User"
|
||||
)
|
||||
# Verify that the expected errors are displayed.
|
||||
errors = self.register_page.wait_for_errors()
|
||||
self.assertIn(u'Please enter your Public Username.', errors)
|
||||
self.assertIn(u'Select your country or region of residence.', errors)
|
||||
self.assertIn(u'Please tell us your favorite movie.', errors)
|
||||
|
||||
def test_toggle_to_login_form(self):
|
||||
self.register_page.visit().toggle_form()
|
||||
self.assertEqual(self.register_page.current_form, "login")
|
||||
|
||||
def test_third_party_register(self):
|
||||
"""
|
||||
Test that we can register using third party credentials, and that the
|
||||
third party account gets linked to the edX account.
|
||||
"""
|
||||
# Navigate to the register page
|
||||
self.register_page.visit()
|
||||
# Baseline screen-shots are different for chrome and firefox.
|
||||
#self.assertScreenshot('#register .login-providers', 'register-providers-{}'.format(self.browser.name), .25)
|
||||
# The line above is commented out temporarily see SOL-1937
|
||||
|
||||
# Try to authenticate using the "Dummy" provider
|
||||
self.register_page.click_third_party_dummy_provider()
|
||||
|
||||
# The user will be redirected somewhere and then back to the register page:
|
||||
msg_text = self.register_page.wait_for_auth_status_message()
|
||||
self.assertEqual(self.register_page.current_form, "register")
|
||||
self.assertIn("You've successfully signed into Dummy", msg_text)
|
||||
self.assertIn("We just need a little more information", msg_text)
|
||||
|
||||
# Now the form should be pre-filled with the data from the Dummy provider:
|
||||
self.assertEqual(self.register_page.email_value, "adama@fleet.colonies.gov")
|
||||
self.assertEqual(self.register_page.full_name_value, "William Adama")
|
||||
self.assertIn("Galactica1", self.register_page.username_value)
|
||||
|
||||
# Set country and submit the form:
|
||||
self.register_page.register(country="US", favorite_movie="Battlestar Galactica")
|
||||
|
||||
# Expect that we reach the dashboard and we're auto-enrolled in the course
|
||||
course_names = self.dashboard_page.wait_for_page().available_courses
|
||||
self.assertIn(self.course_info["display_name"], course_names)
|
||||
|
||||
# Now logout and check that we can log back in instantly (because the account is linked):
|
||||
LogoutPage(self.browser).visit()
|
||||
|
||||
login_page = CombinedLoginAndRegisterPage(self.browser, start_page="login")
|
||||
login_page.visit()
|
||||
login_page.click_third_party_dummy_provider()
|
||||
|
||||
self.dashboard_page.wait_for_page()
|
||||
|
||||
# Now unlink the account (To test the account settings view and also to prevent cross-test side effects)
|
||||
account_settings = AccountSettingsPage(self.browser).visit()
|
||||
# switch to "Linked Accounts" tab
|
||||
account_settings.switch_account_settings_tabs('accounts-tab')
|
||||
|
||||
field_id = "auth-oa2-dummy"
|
||||
account_settings.wait_for_field(field_id)
|
||||
self.assertEqual("Unlink This Account", account_settings.link_title_for_link_field(field_id))
|
||||
account_settings.click_on_link_in_link_field(field_id)
|
||||
account_settings.wait_for_message(field_id, "Successfully unlinked")
|
||||
|
||||
|
||||
@attr('a11y')
|
||||
class CourseWikiA11yTest(UniqueCourseTest):
|
||||
"""
|
||||
@@ -482,293 +110,3 @@ class CourseWikiA11yTest(UniqueCourseTest):
|
||||
})
|
||||
children_page.wait_for_page()
|
||||
children_page.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
|
||||
@attr(shard=1)
|
||||
class VisibleToStaffOnlyTest(UniqueCourseTest):
|
||||
"""
|
||||
Tests that content with visible_to_staff_only set to True cannot be viewed by students.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(VisibleToStaffOnlyTest, self).setUp()
|
||||
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Subsection With Locked Unit').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Locked Unit', metadata={'visible_to_staff_only': True}).add_children(
|
||||
XBlockFixtureDesc('html', 'Html Child in locked unit', data="<html>Visible only to staff</html>"),
|
||||
),
|
||||
XBlockFixtureDesc('vertical', 'Unlocked Unit').add_children(
|
||||
XBlockFixtureDesc('html', 'Html Child in unlocked unit', data="<html>Visible only to all</html>"),
|
||||
)
|
||||
),
|
||||
XBlockFixtureDesc('sequential', 'Unlocked Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('html', 'Html Child in visible unit', data="<html>Visible to all</html>"),
|
||||
)
|
||||
),
|
||||
XBlockFixtureDesc('sequential', 'Locked Subsection', metadata={'visible_to_staff_only': True}).add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc(
|
||||
'html', 'Html Child in locked subsection', data="<html>Visible only to staff</html>"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
self.course_home_page = CourseHomePage(self.browser, self.course_id)
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
|
||||
def test_visible_to_student(self):
|
||||
"""
|
||||
Scenario: Content marked 'visible_to_staff_only' is not visible for students in the course
|
||||
Given some of the course content has been marked 'visible_to_staff_only'
|
||||
And I am logged on with an authorized student account
|
||||
Then I can only see content without 'visible_to_staff_only' set to True
|
||||
"""
|
||||
AutoAuthPage(self.browser, username="STUDENT_TESTER", email="johndoe_student@example.com",
|
||||
course_id=self.course_id, staff=False).visit()
|
||||
|
||||
self.course_home_page.visit()
|
||||
self.assertEqual(2, len(self.course_home_page.outline.sections['Test Section']))
|
||||
|
||||
self.course_home_page.outline.go_to_section("Test Section", "Subsection With Locked Unit")
|
||||
self.courseware_page.wait_for_page()
|
||||
self.assertEqual([u'Unlocked Unit'], self.courseware_page.nav.sequence_items)
|
||||
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.outline.go_to_section("Test Section", "Unlocked Subsection")
|
||||
self.courseware_page.wait_for_page()
|
||||
self.assertEqual([u'Test Unit'], self.courseware_page.nav.sequence_items)
|
||||
|
||||
|
||||
@attr(shard=1)
|
||||
class ProblemExecutionTest(UniqueCourseTest):
|
||||
"""
|
||||
Tests of problems.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Initialize pages and install a course fixture.
|
||||
"""
|
||||
super(ProblemExecutionTest, self).setUp()
|
||||
|
||||
self.course_home_page = CourseHomePage(self.browser, self.course_id)
|
||||
self.tab_nav = TabNavPage(self.browser)
|
||||
|
||||
# Install a course with sections and problems.
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_asset(['python_lib.zip'])
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('problem', 'Python Problem', data=dedent(
|
||||
"""\
|
||||
<problem>
|
||||
<script type="loncapa/python">
|
||||
from number_helpers import seventeen, fortytwo
|
||||
oneseven = seventeen()
|
||||
|
||||
def check_function(expect, ans):
|
||||
if int(ans) == fortytwo(-22):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
</script>
|
||||
|
||||
<p>What is the sum of $oneseven and 3?</p>
|
||||
|
||||
<customresponse expect="20" cfn="check_function">
|
||||
<textline/>
|
||||
</customresponse>
|
||||
</problem>
|
||||
"""
|
||||
))
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
# Auto-auth register for the course
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
def test_python_execution_in_problem(self):
|
||||
# Navigate to the problem page
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.outline.go_to_section('Test Section', 'Test Subsection')
|
||||
|
||||
problem_page = ProblemPage(self.browser)
|
||||
self.assertEqual(problem_page.problem_name.upper(), 'PYTHON PROBLEM')
|
||||
|
||||
# Does the page have computation results?
|
||||
self.assertIn("What is the sum of 17 and 3?", problem_page.problem_text)
|
||||
|
||||
# Fill in the answer correctly.
|
||||
problem_page.fill_answer("20")
|
||||
problem_page.click_submit()
|
||||
self.assertTrue(problem_page.is_correct())
|
||||
|
||||
# Fill in the answer incorrectly.
|
||||
problem_page.fill_answer("4")
|
||||
problem_page.click_submit()
|
||||
self.assertFalse(problem_page.is_correct())
|
||||
|
||||
|
||||
@attr(shard=1)
|
||||
class NotLiveRedirectTest(UniqueCourseTest):
|
||||
"""
|
||||
Test that a banner is shown when the user is redirected to
|
||||
the dashboard from a non-live course.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create a course that isn't live yet and enroll for it."""
|
||||
super(NotLiveRedirectTest, self).setUp()
|
||||
CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name'],
|
||||
start_date=datetime(year=2099, month=1, day=1)
|
||||
).install()
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
def test_redirect_banner(self):
|
||||
"""
|
||||
Navigate to the course info page, then check that we're on the
|
||||
dashboard page with the appropriate message.
|
||||
"""
|
||||
url = BASE_URL + "/courses/" + self.course_id + "/" + 'info'
|
||||
self.browser.get(url)
|
||||
page = DashboardPage(self.browser)
|
||||
page.wait_for_page()
|
||||
self.assertIn(
|
||||
'The course you are looking for does not start until',
|
||||
page.banner_text
|
||||
)
|
||||
|
||||
|
||||
@attr(shard=1)
|
||||
class EnrollmentClosedRedirectTest(UniqueCourseTest):
|
||||
"""
|
||||
Test that a banner is shown when the user is redirected to the
|
||||
dashboard after trying to view the track selection page for a
|
||||
course after enrollment has ended.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create a course that is closed for enrollment, and sign in as a user."""
|
||||
super(EnrollmentClosedRedirectTest, self).setUp()
|
||||
course = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
now = datetime.now(pytz.UTC)
|
||||
course.add_course_details({
|
||||
'enrollment_start': (now - timedelta(days=30)).isoformat(),
|
||||
'enrollment_end': (now - timedelta(days=1)).isoformat()
|
||||
})
|
||||
course.install()
|
||||
|
||||
# Add an honor mode to the course
|
||||
ModeCreationPage(self.browser, self.course_id).visit()
|
||||
|
||||
# Add a verified mode to the course
|
||||
ModeCreationPage(
|
||||
self.browser,
|
||||
self.course_id,
|
||||
mode_slug=u'verified',
|
||||
mode_display_name=u'Verified Certificate',
|
||||
min_price=10,
|
||||
suggested_prices='10,20'
|
||||
).visit()
|
||||
|
||||
def _assert_dashboard_message(self):
|
||||
"""
|
||||
Assert that the 'closed for enrollment' text is present on the
|
||||
dashboard.
|
||||
"""
|
||||
page = DashboardPage(self.browser)
|
||||
page.wait_for_page()
|
||||
self.assertIn(
|
||||
'The course you are looking for is closed for enrollment',
|
||||
page.banner_text
|
||||
)
|
||||
|
||||
def test_redirect_banner(self):
|
||||
"""
|
||||
Navigate to the course info page, then check that we're on the
|
||||
dashboard page with the appropriate message.
|
||||
"""
|
||||
AutoAuthPage(self.browser).visit()
|
||||
url = BASE_URL + "/course_modes/choose/" + self.course_id
|
||||
self.browser.get(url)
|
||||
self._assert_dashboard_message()
|
||||
|
||||
|
||||
@attr(shard=19)
|
||||
class RegisterCourseTests(EventsTestMixin, UniqueCourseTest):
|
||||
"""Test that learner can enroll into a course from courses page"""
|
||||
|
||||
TEST_INDEX_FILENAME = "test_root/index_file.dat"
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Initialize the test.
|
||||
|
||||
Create the necessary page objects, create course page and courses to find.
|
||||
"""
|
||||
super(RegisterCourseTests, self).setUp()
|
||||
|
||||
# create test file in which index for this test will live
|
||||
with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
|
||||
json.dump({}, index_file)
|
||||
self.addCleanup(remove_file, self.TEST_INDEX_FILENAME)
|
||||
|
||||
self.course_discovery = CourseDiscoveryPage(self.browser)
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
self.course_about = CourseAboutPage(self.browser, self.course_id)
|
||||
|
||||
# Create a course
|
||||
CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name'],
|
||||
settings={'enrollment_start': datetime(1970, 1, 1).isoformat()}
|
||||
).install()
|
||||
|
||||
# Create a user and log them in
|
||||
AutoAuthPage(self.browser).visit()
|
||||
|
||||
def test_register_for_course(self):
|
||||
"""
|
||||
Scenario: I can register for a course
|
||||
Given The course "6.002x" exists
|
||||
And I am logged in
|
||||
And I visit the courses page
|
||||
When I register for the course "6.002x"
|
||||
Then I should see the course numbered "6.002x" in my dashboard
|
||||
And a "edx.course.enrollment.activated" server event is emitted
|
||||
"""
|
||||
# Navigate to the dashboard
|
||||
self.course_discovery.visit()
|
||||
self.course_discovery.click_course(self.course_id)
|
||||
self.course_about.wait_for_page()
|
||||
self.course_about.enroll_in_course()
|
||||
self.dashboard_page.wait_for_page()
|
||||
self.assertTrue(self.dashboard_page.is_course_present(self.course_id))
|
||||
self.assert_matching_events_were_emitted(
|
||||
event_filter={'name': u'edx.course.enrollment.activated', 'event_source': 'server'}
|
||||
)
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
End-to-end tests for the LMS.
|
||||
"""
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.pages.xblock.acid import AcidView
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest
|
||||
|
||||
|
||||
class XBlockAcidBase(UniqueCourseTest):
|
||||
"""
|
||||
Base class for tests that verify that XBlock integration is working correctly
|
||||
"""
|
||||
__test__ = False
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a unique identifier for the course used in this test.
|
||||
"""
|
||||
# Ensure that the superclass sets up
|
||||
super(XBlockAcidBase, self).setUp()
|
||||
|
||||
self.setup_fixtures()
|
||||
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
|
||||
def validate_acid_block_view(self, acid_block):
|
||||
"""
|
||||
Verify that the LMS view for the Acid Block is correct
|
||||
"""
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.resource_url_passed)
|
||||
self.assertTrue(acid_block.scope_passed('user_state'))
|
||||
self.assertTrue(acid_block.scope_passed('user_state_summary'))
|
||||
self.assertTrue(acid_block.scope_passed('preferences'))
|
||||
self.assertTrue(acid_block.scope_passed('user_info'))
|
||||
|
||||
|
||||
class XBlockAcidNoChildTest(XBlockAcidBase):
|
||||
"""
|
||||
Tests of an AcidBlock with no children
|
||||
"""
|
||||
shard = 20
|
||||
__test__ = True
|
||||
|
||||
def setup_fixtures(self):
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('acid', 'Acid Block')
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
def test_acid_block(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in the lms.
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
acid_block = AcidView(self.browser, '.xblock-student_view[data-block-type=acid]')
|
||||
self.validate_acid_block_view(acid_block)
|
||||
|
||||
|
||||
class XBlockAcidChildTest(XBlockAcidBase):
|
||||
"""
|
||||
Tests of an AcidBlock with children
|
||||
"""
|
||||
shard = 20
|
||||
__test__ = True
|
||||
|
||||
def setup_fixtures(self):
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
|
||||
XBlockFixtureDesc('acid', 'First Acid Child', metadata={'name': 'first'}),
|
||||
XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}),
|
||||
XBlockFixtureDesc('html', 'Html Child', data="<html>Contents</html>"),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
def validate_acid_parent_block_view(self, acid_parent_block):
|
||||
super(XBlockAcidChildTest, self).validate_acid_block_view(acid_parent_block)
|
||||
self.assertTrue(acid_parent_block.child_tests_passed)
|
||||
|
||||
def test_acid_block(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in the lms.
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
acid_parent_block = AcidView(self.browser, '.xblock-student_view[data-block-type=acid_parent]')
|
||||
self.validate_acid_parent_block_view(acid_parent_block)
|
||||
|
||||
acid_block = AcidView(self.browser, '.xblock-student_view[data-block-type=acid]')
|
||||
self.validate_acid_block_view(acid_block)
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
class XBlockAcidAsideTest(XBlockAcidBase):
|
||||
"""
|
||||
Tests of an AcidBlock with children
|
||||
"""
|
||||
shard = 20
|
||||
__test__ = True
|
||||
|
||||
def setup_fixtures(self):
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('acid', 'Acid Block')
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
def test_acid_block(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in the lms.
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
acid_aside = AcidView(self.browser, '.xblock_asides-v1-student_view[data-block-type=acid_aside]')
|
||||
self.validate_acid_aside_view(acid_aside)
|
||||
|
||||
acid_block = AcidView(self.browser, '.xblock-student_view[data-block-type=acid]')
|
||||
self.validate_acid_block_view(acid_block)
|
||||
|
||||
def validate_acid_aside_view(self, acid_aside):
|
||||
self.validate_acid_block_view(acid_aside)
|
||||
@@ -1,271 +0,0 @@
|
||||
"""
|
||||
Test courseware search
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.common.logout import LogoutPage
|
||||
from common.test.acceptance.pages.lms.course_home import CourseHomePage
|
||||
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage
|
||||
from common.test.acceptance.pages.lms.staff_view import StaffCoursewarePage
|
||||
from common.test.acceptance.pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
|
||||
from common.test.acceptance.pages.studio.settings_group_configurations import GroupConfigurationsPage
|
||||
from common.test.acceptance.pages.studio.xblock_editor import XBlockVisibilityEditorView
|
||||
from common.test.acceptance.tests.discussion.helpers import CohortTestMixin
|
||||
from common.test.acceptance.tests.helpers import remove_file
|
||||
from common.test.acceptance.tests.studio.base_studio_test import ContainerBase
|
||||
|
||||
|
||||
class CoursewareSearchCohortTest(ContainerBase, CohortTestMixin):
|
||||
"""
|
||||
Test courseware search.
|
||||
"""
|
||||
shard = 1
|
||||
TEST_INDEX_FILENAME = "test_root/index_file.dat"
|
||||
|
||||
def setUp(self, is_staff=True):
|
||||
"""
|
||||
Create search page and course content to search
|
||||
"""
|
||||
# create test file in which index for this test will live
|
||||
with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
|
||||
json.dump({}, index_file)
|
||||
self.addCleanup(remove_file, self.TEST_INDEX_FILENAME)
|
||||
|
||||
super(CoursewareSearchCohortTest, self).setUp(is_staff=is_staff)
|
||||
self.staff_user = self.user
|
||||
|
||||
self.studio_course_outline = StudioCourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
self.content_group_a = "Content Group A"
|
||||
self.content_group_b = "Content Group B"
|
||||
|
||||
# Create a student who will be in "Cohort A"
|
||||
self.cohort_a_student_username = "cohort_a_" + str(uuid.uuid4().hex)[:12]
|
||||
self.cohort_a_student_email = self.cohort_a_student_username + "@example.com"
|
||||
AutoAuthPage(
|
||||
self.browser, username=self.cohort_a_student_username, email=self.cohort_a_student_email, no_login=True
|
||||
).visit()
|
||||
|
||||
# Create a student who will be in "Cohort B"
|
||||
self.cohort_b_student_username = "cohort_b_" + str(uuid.uuid4().hex)[:12]
|
||||
self.cohort_b_student_email = self.cohort_b_student_username + "@example.com"
|
||||
AutoAuthPage(
|
||||
self.browser, username=self.cohort_b_student_username, email=self.cohort_b_student_email, no_login=True
|
||||
).visit()
|
||||
|
||||
# Create a student who will end up in the default cohort group
|
||||
self.cohort_default_student_username = "cohort_default_student"
|
||||
self.cohort_default_student_email = "cohort_default_student@example.com"
|
||||
AutoAuthPage(
|
||||
self.browser, username=self.cohort_default_student_username,
|
||||
email=self.cohort_default_student_email, no_login=True
|
||||
).visit()
|
||||
|
||||
self.course_home_page = CourseHomePage(self.browser, self.course_id)
|
||||
|
||||
# Enable Cohorting and assign cohorts and content groups
|
||||
self._auto_auth(self.staff_user["username"], self.staff_user["email"], True)
|
||||
self.enable_cohorting(self.course_fixture)
|
||||
self.create_content_groups()
|
||||
self.link_html_to_content_groups_and_publish()
|
||||
self.create_cohorts_and_assign_students()
|
||||
|
||||
self._studio_reindex()
|
||||
|
||||
def _auto_auth(self, username, email, staff):
|
||||
"""
|
||||
Logout and login with given credentials.
|
||||
"""
|
||||
LogoutPage(self.browser).visit()
|
||||
AutoAuthPage(self.browser, username=username, email=email,
|
||||
course_id=self.course_id, staff=staff).visit()
|
||||
|
||||
def _studio_reindex(self):
|
||||
"""
|
||||
Reindex course content on studio course page
|
||||
"""
|
||||
self._auto_auth(self.staff_user["username"], self.staff_user["email"], True)
|
||||
self.studio_course_outline.visit()
|
||||
self.studio_course_outline.start_reindex()
|
||||
self.studio_course_outline.wait_for_ajax()
|
||||
|
||||
def _goto_staff_page(self):
|
||||
"""
|
||||
Open staff page with assertion
|
||||
"""
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.resume_course_from_header()
|
||||
staff_page = StaffCoursewarePage(self.browser, self.course_id)
|
||||
self.assertEqual(staff_page.staff_view_mode, 'Staff')
|
||||
return staff_page
|
||||
|
||||
def _search_for_term(self, term):
|
||||
"""
|
||||
Search for term in course and return results.
|
||||
"""
|
||||
self.course_home_page.visit()
|
||||
course_search_results_page = self.course_home_page.search_for_term(term)
|
||||
results = course_search_results_page.search_results.html
|
||||
return results[0] if len(results) > 0 else []
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Populate the children of the test course fixture.
|
||||
"""
|
||||
self.group_a_html = 'GROUPACONTENT'
|
||||
self.group_b_html = 'GROUPBCONTENT'
|
||||
self.group_a_and_b_html = 'GROUPAANDBCONTENT'
|
||||
self.visible_to_all_html = 'VISIBLETOALLCONTENT'
|
||||
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('html', self.group_a_html, data='<html>GROUPACONTENT</html>'),
|
||||
XBlockFixtureDesc('html', self.group_b_html, data='<html>GROUPBCONTENT</html>'),
|
||||
XBlockFixtureDesc('html', self.group_a_and_b_html, data='<html>GROUPAANDBCONTENT</html>'),
|
||||
XBlockFixtureDesc('html', self.visible_to_all_html, data='<html>VISIBLETOALLCONTENT</html>')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def create_content_groups(self):
|
||||
"""
|
||||
Creates two content groups in Studio Group Configurations Settings.
|
||||
"""
|
||||
group_configurations_page = GroupConfigurationsPage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
group_configurations_page.visit()
|
||||
|
||||
group_configurations_page.create_first_content_group()
|
||||
config = group_configurations_page.content_groups[0]
|
||||
config.name = self.content_group_a
|
||||
config.save()
|
||||
|
||||
group_configurations_page.add_content_group()
|
||||
config = group_configurations_page.content_groups[1]
|
||||
config.name = self.content_group_b
|
||||
config.save()
|
||||
|
||||
def link_html_to_content_groups_and_publish(self):
|
||||
"""
|
||||
Updates 3 of the 4 existing html to limit their visibility by content group.
|
||||
Publishes the modified units.
|
||||
"""
|
||||
container_page = self.go_to_unit_page()
|
||||
|
||||
def set_visibility(html_block_index, groups):
|
||||
"""
|
||||
Set visibility on html blocks to specified groups.
|
||||
"""
|
||||
html_block = container_page.xblocks[html_block_index]
|
||||
html_block.edit_visibility()
|
||||
visibility_dialog = XBlockVisibilityEditorView(self.browser, html_block.locator)
|
||||
visibility_dialog.select_groups_in_partition_scheme(visibility_dialog.CONTENT_GROUP_PARTITION, groups)
|
||||
|
||||
set_visibility(1, [self.content_group_a])
|
||||
set_visibility(2, [self.content_group_b])
|
||||
set_visibility(3, [self.content_group_a, self.content_group_b])
|
||||
|
||||
container_page.publish()
|
||||
|
||||
def create_cohorts_and_assign_students(self):
|
||||
"""
|
||||
Adds 2 manual cohorts, linked to content groups, to the course.
|
||||
Each cohort is assigned one student.
|
||||
"""
|
||||
instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id)
|
||||
instructor_dashboard_page.visit()
|
||||
cohort_management_page = instructor_dashboard_page.select_cohort_management()
|
||||
|
||||
def add_cohort_with_student(cohort_name, content_group, student):
|
||||
"""
|
||||
Create cohort and assign student to it.
|
||||
"""
|
||||
cohort_management_page.add_cohort(cohort_name, content_group=content_group)
|
||||
cohort_management_page.add_students_to_selected_cohort([student])
|
||||
add_cohort_with_student("Cohort A", self.content_group_a, self.cohort_a_student_username)
|
||||
add_cohort_with_student("Cohort B", self.content_group_b, self.cohort_b_student_username)
|
||||
cohort_management_page.wait_for_ajax()
|
||||
|
||||
def test_cohorted_search_user_a_a_content(self):
|
||||
"""
|
||||
Test user can search content restricted to his cohort.
|
||||
"""
|
||||
self._auto_auth(self.cohort_a_student_username, self.cohort_a_student_email, False)
|
||||
search_results = self._search_for_term(self.group_a_html)
|
||||
assert self.group_a_html in search_results
|
||||
|
||||
def test_cohorted_search_user_b_a_content(self):
|
||||
"""
|
||||
Test user can not search content restricted to his cohort.
|
||||
"""
|
||||
self._auto_auth(self.cohort_b_student_username, self.cohort_b_student_email, False)
|
||||
search_results = self._search_for_term(self.group_a_html)
|
||||
assert self.group_a_html not in search_results
|
||||
|
||||
def test_cohorted_search_user_staff_all_content(self):
|
||||
"""
|
||||
Test staff user can search all public content if cohorts used on course.
|
||||
"""
|
||||
self._auto_auth(self.staff_user["username"], self.staff_user["email"], False)
|
||||
self._goto_staff_page().set_staff_view_mode('Staff')
|
||||
search_results = self._search_for_term(self.visible_to_all_html)
|
||||
assert self.visible_to_all_html in search_results
|
||||
search_results = self._search_for_term(self.group_a_and_b_html)
|
||||
assert self.group_a_and_b_html in search_results
|
||||
search_results = self._search_for_term(self.group_a_html)
|
||||
assert self.group_a_html in search_results
|
||||
search_results = self._search_for_term(self.group_b_html)
|
||||
assert self.group_b_html in search_results
|
||||
|
||||
def test_cohorted_search_user_staff_masquerade_student_content(self):
|
||||
"""
|
||||
Test staff user can search just student public content if selected from preview menu.
|
||||
|
||||
NOTE: Although it would be wise to combine these masquerading tests into
|
||||
a single test due to expensive setup, doing so revealed a very low
|
||||
priority bug where searching seems to stick/cache the access of the
|
||||
first user who searches for future searches.
|
||||
|
||||
"""
|
||||
self._auto_auth(self.staff_user["username"], self.staff_user["email"], False)
|
||||
self._goto_staff_page().set_staff_view_mode('Learner')
|
||||
search_results = self._search_for_term(self.visible_to_all_html)
|
||||
assert self.visible_to_all_html in search_results
|
||||
search_results = self._search_for_term(self.group_a_and_b_html)
|
||||
assert self.group_a_and_b_html not in search_results
|
||||
search_results = self._search_for_term(self.group_a_html)
|
||||
assert self.group_a_html not in search_results
|
||||
search_results = self._search_for_term(self.group_b_html)
|
||||
assert self.group_b_html not in search_results
|
||||
|
||||
def test_cohorted_search_user_staff_masquerade_cohort_content(self):
|
||||
"""
|
||||
Test staff user can search cohort and public content if selected from preview menu.
|
||||
"""
|
||||
self._auto_auth(self.staff_user["username"], self.staff_user["email"], False)
|
||||
self._goto_staff_page().set_staff_view_mode('Learner in ' + self.content_group_a)
|
||||
search_results = self._search_for_term(self.visible_to_all_html)
|
||||
assert self.visible_to_all_html in search_results
|
||||
search_results = self._search_for_term(self.group_a_and_b_html)
|
||||
assert self.group_a_and_b_html in search_results
|
||||
search_results = self._search_for_term(self.group_a_html)
|
||||
assert self.group_a_html in search_results
|
||||
search_results = self._search_for_term(self.group_b_html)
|
||||
assert self.group_b_html not in search_results
|
||||
@@ -1,74 +0,0 @@
|
||||
"""
|
||||
Test course discovery.
|
||||
"""
|
||||
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from six.moves import range
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.common.logout import LogoutPage
|
||||
from common.test.acceptance.pages.lms.discovery import CourseDiscoveryPage
|
||||
from common.test.acceptance.tests.helpers import AcceptanceTest, remove_file
|
||||
|
||||
|
||||
class CourseDiscoveryTest(AcceptanceTest):
|
||||
"""
|
||||
Test searching for courses.
|
||||
"""
|
||||
shard = 20
|
||||
|
||||
STAFF_USERNAME = "STAFF_TESTER"
|
||||
STAFF_EMAIL = "staff101@example.com"
|
||||
TEST_INDEX_FILENAME = "test_root/index_file.dat"
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create course page and courses to find
|
||||
"""
|
||||
# create index file
|
||||
with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
|
||||
json.dump({}, index_file)
|
||||
|
||||
self.addCleanup(remove_file, self.TEST_INDEX_FILENAME)
|
||||
|
||||
super(CourseDiscoveryTest, self).setUp()
|
||||
self.page = CourseDiscoveryPage(self.browser)
|
||||
|
||||
for i in range(12):
|
||||
org = 'test_org'
|
||||
number = "{}{}".format(str(i), str(uuid.uuid4().hex.upper()[0:6]))
|
||||
run = "test_run"
|
||||
name = "test course" if i < 10 else "grass is always greener"
|
||||
settings = {'enrollment_start': datetime.datetime(1970, 1, 1).isoformat()}
|
||||
CourseFixture(org, number, run, name, settings=settings).install()
|
||||
|
||||
def _auto_auth(self, username, email, staff):
|
||||
"""
|
||||
Logout and login with given credentials.
|
||||
"""
|
||||
LogoutPage(self.browser).visit()
|
||||
AutoAuthPage(self.browser, username=username, email=email, staff=staff).visit()
|
||||
|
||||
def test_page_existence(self):
|
||||
"""
|
||||
Make sure that the page is accessible.
|
||||
"""
|
||||
self.page.visit()
|
||||
|
||||
def test_search(self):
|
||||
"""
|
||||
Make sure you can search for courses.
|
||||
"""
|
||||
self.page.visit()
|
||||
self.assertEqual(len(self.page.result_items), 12)
|
||||
|
||||
self.page.search("grass")
|
||||
self.assertEqual(len(self.page.result_items), 2)
|
||||
|
||||
self.page.clear_search()
|
||||
self.assertEqual(len(self.page.result_items), 12)
|
||||
@@ -3,16 +3,9 @@
|
||||
End-to-end tests for the LMS that utilize the course home page and course outline.
|
||||
"""
|
||||
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import six
|
||||
|
||||
from common.test.acceptance.pages.lms.create_mode import ModeCreationPage
|
||||
from openedx.core.lib.tests import attr
|
||||
|
||||
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from ...pages.lms.bookmarks import BookmarksPage
|
||||
from ...pages.lms.course_home import CourseHomePage
|
||||
from ...pages.lms.courseware import CoursewarePage
|
||||
from ..helpers import UniqueCourseTest, auto_auth, load_data_str
|
||||
|
||||
@@ -1,646 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
End-to-end tests for the LMS.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from six.moves import range
|
||||
|
||||
from openedx.core.lib.tests import attr
|
||||
|
||||
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from ...pages.common.auto_auth import AutoAuthPage
|
||||
from ...pages.common.logout import LogoutPage
|
||||
from ...pages.lms.course_home import CourseHomePage
|
||||
from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage
|
||||
from ...pages.lms.problem import ProblemPage
|
||||
from ...pages.lms.progress import ProgressPage
|
||||
from ...pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
|
||||
from ..helpers import EventsTestMixin, UniqueCourseTest, auto_auth, create_multiple_choice_problem
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class CoursewareTest(UniqueCourseTest):
|
||||
"""
|
||||
Test courseware.
|
||||
"""
|
||||
USERNAME = "STUDENT_TESTER"
|
||||
EMAIL = "student101@example.com"
|
||||
|
||||
def setUp(self):
|
||||
super(CoursewareTest, self).setUp()
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
self.course_home_page = CourseHomePage(self.browser, self.course_id)
|
||||
|
||||
self.studio_course_outline = StudioCourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
# Install a course with sections/problems, tabs, updates, and handouts
|
||||
self.course_fix = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
|
||||
self.course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 1').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Problem 1')
|
||||
)
|
||||
),
|
||||
XBlockFixtureDesc('chapter', 'Test Section 2').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 2').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Problem 2')
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
# Auto-auth register for the course.
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, False, self.course_id)
|
||||
|
||||
def _goto_problem_page(self):
|
||||
"""
|
||||
Open problem page with assertion.
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
self.problem_page = ProblemPage(self.browser) # pylint: disable=attribute-defined-outside-init
|
||||
self.assertEqual(self.problem_page.problem_name, 'Test Problem 1')
|
||||
|
||||
def test_courseware(self):
|
||||
"""
|
||||
Test courseware if recent visited subsection become unpublished.
|
||||
"""
|
||||
|
||||
# Visit problem page as a student.
|
||||
self._goto_problem_page()
|
||||
|
||||
# Logout and login as a staff user.
|
||||
LogoutPage(self.browser).visit()
|
||||
auto_auth(self.browser, "STAFF_TESTER", "staff101@example.com", True, self.course_id)
|
||||
|
||||
# Visit course outline page in studio.
|
||||
self.studio_course_outline.visit()
|
||||
|
||||
# Set release date for subsection in future.
|
||||
self.studio_course_outline.change_problem_release_date()
|
||||
|
||||
# Logout and login as a student.
|
||||
LogoutPage(self.browser).visit()
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, False, self.course_id)
|
||||
|
||||
# Visit courseware as a student.
|
||||
self.courseware_page.visit()
|
||||
# Problem name should be "Test Problem 2".
|
||||
self.assertEqual(self.problem_page.problem_name, 'Test Problem 2')
|
||||
|
||||
def test_course_tree_breadcrumb(self):
|
||||
"""
|
||||
Scenario: Correct course tree breadcrumb is shown.
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
Then I should see correct course tree breadcrumb
|
||||
"""
|
||||
xblocks = self.course_fix.get_nested_xblocks(category="problem")
|
||||
for index in range(1, len(xblocks) + 1):
|
||||
test_section_title = u'Test Section {}'.format(index)
|
||||
test_subsection_title = u'Test Subsection {}'.format(index)
|
||||
test_unit_title = u'Test Problem {}'.format(index)
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.outline.go_to_section(test_section_title, test_subsection_title)
|
||||
course_nav = self.courseware_page.nav
|
||||
self.assertEqual(course_nav.breadcrumb_section_title, test_section_title)
|
||||
self.assertEqual(course_nav.breadcrumb_subsection_title, test_subsection_title)
|
||||
self.assertEqual(course_nav.breadcrumb_unit_title, test_unit_title)
|
||||
|
||||
|
||||
class CoursewareMultipleVerticalsTestBase(UniqueCourseTest, EventsTestMixin):
|
||||
"""
|
||||
Base class with setup for testing courseware with multiple verticals
|
||||
"""
|
||||
USERNAME = "STUDENT_TESTER"
|
||||
EMAIL = "student101@example.com"
|
||||
|
||||
def setUp(self):
|
||||
super(CoursewareMultipleVerticalsTestBase, self).setUp()
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
self.course_home_page = CourseHomePage(self.browser, self.course_id)
|
||||
|
||||
self.studio_course_outline = StudioCourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
# Install a course with sections/problems, tabs, updates, and handouts
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 1,1').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Problem 1', data='<problem>problem 1 dummy body</problem>'),
|
||||
XBlockFixtureDesc('html', 'html 1', data="<html>html 1 dummy body</html>"),
|
||||
XBlockFixtureDesc('problem', 'Test Problem 2', data="<problem>problem 2 dummy body</problem>"),
|
||||
XBlockFixtureDesc('html', 'html 2', data="<html>html 2 dummy body</html>"),
|
||||
),
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 1,2').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Problem 3', data='<problem>problem 3 dummy body</problem>'),
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
'sequential', 'Test HIDDEN Subsection', metadata={'visible_to_staff_only': True}
|
||||
).add_children(
|
||||
XBlockFixtureDesc('problem', 'Test HIDDEN Problem', data='<problem>hidden problem</problem>'),
|
||||
),
|
||||
),
|
||||
XBlockFixtureDesc('chapter', 'Test Section 2').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 2,1').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Problem 4', data='<problem>problem 4 dummy body</problem>'),
|
||||
),
|
||||
),
|
||||
XBlockFixtureDesc('chapter', 'Test HIDDEN Section', metadata={'visible_to_staff_only': True}).add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test HIDDEN Subsection'),
|
||||
),
|
||||
).install()
|
||||
|
||||
# Auto-auth register for the course.
|
||||
AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL,
|
||||
course_id=self.course_id, staff=False).visit()
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class CoursewareMultipleVerticalsTest(CoursewareMultipleVerticalsTestBase):
|
||||
"""
|
||||
Test courseware with multiple verticals
|
||||
"""
|
||||
|
||||
def test_navigation_buttons(self):
|
||||
self.courseware_page.visit()
|
||||
|
||||
# start in first section
|
||||
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 0, next_enabled=True, prev_enabled=False)
|
||||
|
||||
# next takes us to next tab in sequential
|
||||
self.courseware_page.click_next_button_on_top()
|
||||
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 1, next_enabled=True, prev_enabled=True)
|
||||
|
||||
# go to last sequential position
|
||||
self.courseware_page.go_to_sequential_position(4)
|
||||
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 3, next_enabled=True, prev_enabled=True)
|
||||
|
||||
# next takes us to next sequential
|
||||
self.courseware_page.click_next_button_on_bottom()
|
||||
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,2', 0, next_enabled=True, prev_enabled=True)
|
||||
|
||||
# next takes us to next chapter
|
||||
self.courseware_page.click_next_button_on_top()
|
||||
self.assert_navigation_state('Test Section 2', 'Test Subsection 2,1', 0, next_enabled=False, prev_enabled=True)
|
||||
|
||||
# previous takes us to previous chapter
|
||||
self.courseware_page.click_previous_button_on_top()
|
||||
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,2', 0, next_enabled=True, prev_enabled=True)
|
||||
|
||||
# previous takes us to last tab in previous sequential
|
||||
self.courseware_page.click_previous_button_on_bottom()
|
||||
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 3, next_enabled=True, prev_enabled=True)
|
||||
|
||||
# previous takes us to previous tab in sequential
|
||||
self.courseware_page.click_previous_button_on_bottom()
|
||||
self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 2, next_enabled=True, prev_enabled=True)
|
||||
|
||||
# test UI events emitted by navigation
|
||||
filter_sequence_ui_event = lambda event: event.get('name', '').startswith('edx.ui.lms.sequence.')
|
||||
|
||||
sequence_ui_events = self.wait_for_events(event_filter=filter_sequence_ui_event, timeout=2)
|
||||
legacy_events = [ev for ev in sequence_ui_events if ev['event_type'] in {'seq_next', 'seq_prev', 'seq_goto'}]
|
||||
nonlegacy_events = [ev for ev in sequence_ui_events if ev not in legacy_events]
|
||||
|
||||
self.assertTrue(all('old' in json.loads(ev['event']) for ev in legacy_events))
|
||||
self.assertTrue(all('new' in json.loads(ev['event']) for ev in legacy_events))
|
||||
self.assertFalse(any('old' in json.loads(ev['event']) for ev in nonlegacy_events))
|
||||
self.assertFalse(any('new' in json.loads(ev['event']) for ev in nonlegacy_events))
|
||||
|
||||
self.assert_events_match(
|
||||
[
|
||||
{
|
||||
'event_type': 'seq_next',
|
||||
'event': {
|
||||
'old': 1,
|
||||
'new': 2,
|
||||
'current_tab': 1,
|
||||
'tab_count': 4,
|
||||
'widget_placement': 'top',
|
||||
}
|
||||
},
|
||||
{
|
||||
'event_type': 'seq_goto',
|
||||
'event': {
|
||||
'old': 2,
|
||||
'new': 4,
|
||||
'current_tab': 2,
|
||||
'target_tab': 4,
|
||||
'tab_count': 4,
|
||||
'widget_placement': 'top',
|
||||
}
|
||||
},
|
||||
{
|
||||
'event_type': 'edx.ui.lms.sequence.next_selected',
|
||||
'event': {
|
||||
'current_tab': 4,
|
||||
'tab_count': 4,
|
||||
'widget_placement': 'bottom',
|
||||
}
|
||||
},
|
||||
{
|
||||
'event_type': 'edx.ui.lms.sequence.next_selected',
|
||||
'event': {
|
||||
'current_tab': 1,
|
||||
'tab_count': 1,
|
||||
'widget_placement': 'top',
|
||||
}
|
||||
},
|
||||
{
|
||||
'event_type': 'edx.ui.lms.sequence.previous_selected',
|
||||
'event': {
|
||||
'current_tab': 1,
|
||||
'tab_count': 1,
|
||||
'widget_placement': 'top',
|
||||
}
|
||||
},
|
||||
{
|
||||
'event_type': 'edx.ui.lms.sequence.previous_selected',
|
||||
'event': {
|
||||
'current_tab': 1,
|
||||
'tab_count': 1,
|
||||
'widget_placement': 'bottom',
|
||||
}
|
||||
},
|
||||
{
|
||||
'event_type': 'seq_prev',
|
||||
'event': {
|
||||
'old': 4,
|
||||
'new': 3,
|
||||
'current_tab': 4,
|
||||
'tab_count': 4,
|
||||
'widget_placement': 'bottom',
|
||||
}
|
||||
},
|
||||
],
|
||||
sequence_ui_events
|
||||
)
|
||||
|
||||
def assert_navigation_state(
|
||||
self, section_title, subsection_title, subsection_position, next_enabled, prev_enabled
|
||||
):
|
||||
"""
|
||||
Verifies that the navigation state is as expected.
|
||||
"""
|
||||
self.assertTrue(self.courseware_page.nav.is_on_section(section_title, subsection_title))
|
||||
self.assertEqual(self.courseware_page.sequential_position, subsection_position)
|
||||
self.assertEqual(self.courseware_page.is_next_button_enabled, next_enabled)
|
||||
self.assertEqual(self.courseware_page.is_previous_button_enabled, prev_enabled)
|
||||
|
||||
def test_tab_position(self):
|
||||
# test that using the position in the url direct to correct tab in courseware
|
||||
self.course_home_page.visit()
|
||||
|
||||
self.course_home_page.outline.go_to_section('Test Section 1', 'Test Subsection 1,1')
|
||||
subsection_url = self.browser.current_url
|
||||
url_part_list = subsection_url.split('/')
|
||||
|
||||
course_id = url_part_list[-5]
|
||||
chapter_id = url_part_list[-3]
|
||||
subsection_id = url_part_list[-2]
|
||||
problem1_page = CoursewareSequentialTabPage(
|
||||
self.browser,
|
||||
course_id=course_id,
|
||||
chapter=chapter_id,
|
||||
subsection=subsection_id,
|
||||
position=1
|
||||
).visit()
|
||||
self.assertIn('problem 1 dummy body', problem1_page.get_selected_tab_content())
|
||||
|
||||
html1_page = CoursewareSequentialTabPage(
|
||||
self.browser,
|
||||
course_id=course_id,
|
||||
chapter=chapter_id,
|
||||
subsection=subsection_id,
|
||||
position=2
|
||||
).visit()
|
||||
self.assertIn('html 1 dummy body', html1_page.get_selected_tab_content())
|
||||
|
||||
problem2_page = CoursewareSequentialTabPage(
|
||||
self.browser,
|
||||
course_id=course_id,
|
||||
chapter=chapter_id,
|
||||
subsection=subsection_id,
|
||||
position=3
|
||||
).visit()
|
||||
self.assertIn('problem 2 dummy body', problem2_page.get_selected_tab_content())
|
||||
|
||||
html2_page = CoursewareSequentialTabPage(
|
||||
self.browser,
|
||||
course_id=course_id,
|
||||
chapter=chapter_id,
|
||||
subsection=subsection_id,
|
||||
position=4
|
||||
).visit()
|
||||
self.assertIn('html 2 dummy body', html2_page.get_selected_tab_content())
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemStateOnNavigationTest(UniqueCourseTest):
|
||||
"""
|
||||
Test courseware with problems in multiple verticals.
|
||||
"""
|
||||
USERNAME = "STUDENT_TESTER"
|
||||
EMAIL = "student101@example.com"
|
||||
|
||||
problem1_name = 'MULTIPLE CHOICE TEST PROBLEM 1'
|
||||
problem2_name = 'MULTIPLE CHOICE TEST PROBLEM 2'
|
||||
|
||||
def setUp(self):
|
||||
super(ProblemStateOnNavigationTest, self).setUp()
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
|
||||
# Install a course with section, tabs and multiple choice problems.
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 1,1').add_children(
|
||||
create_multiple_choice_problem(self.problem1_name),
|
||||
create_multiple_choice_problem(self.problem2_name),
|
||||
),
|
||||
),
|
||||
).install()
|
||||
|
||||
# Auto-auth register for the course.
|
||||
AutoAuthPage(
|
||||
self.browser, username=self.USERNAME, email=self.EMAIL,
|
||||
course_id=self.course_id, staff=False
|
||||
).visit()
|
||||
|
||||
self.courseware_page.visit()
|
||||
self.problem_page = ProblemPage(self.browser)
|
||||
|
||||
def go_to_tab_and_assert_problem(self, position, problem_name):
|
||||
"""
|
||||
Go to sequential tab and assert that we are on problem whose name is given as a parameter.
|
||||
Args:
|
||||
position: Position of the sequential tab
|
||||
problem_name: Name of the problem
|
||||
"""
|
||||
self.courseware_page.go_to_sequential_position(position)
|
||||
self.problem_page.wait_for_element_presence(
|
||||
self.problem_page.CSS_PROBLEM_HEADER,
|
||||
'wait for problem header'
|
||||
)
|
||||
self.assertEqual(self.problem_page.problem_name, problem_name)
|
||||
|
||||
def test_perform_problem_submit_and_navigate(self):
|
||||
"""
|
||||
Scenario:
|
||||
I go to sequential position 1
|
||||
Facing problem1, I select 'choice_1'
|
||||
Then I click submit button
|
||||
Then I go to sequential position 2
|
||||
Then I came back to sequential position 1 again
|
||||
Facing problem1, I observe the problem1 content is not
|
||||
outdated before and after sequence navigation
|
||||
"""
|
||||
# Go to sequential position 1 and assert that we are on problem 1.
|
||||
self.go_to_tab_and_assert_problem(1, self.problem1_name)
|
||||
|
||||
# Update problem 1's content state by clicking check button.
|
||||
self.problem_page.click_choice('choice_choice_1')
|
||||
self.problem_page.click_submit()
|
||||
self.problem_page.wait_for_expected_status('label.choicegroup_incorrect', 'incorrect')
|
||||
|
||||
# Save problem 1's content state as we're about to switch units in the sequence.
|
||||
problem1_content_before_switch = self.problem_page.problem_content
|
||||
before_meta = self.problem_page.problem_meta
|
||||
|
||||
# Go to sequential position 2 and assert that we are on problem 2.
|
||||
self.go_to_tab_and_assert_problem(2, self.problem2_name)
|
||||
|
||||
# Come back to our original unit in the sequence and assert that the content hasn't changed.
|
||||
self.go_to_tab_and_assert_problem(1, self.problem1_name)
|
||||
problem1_content_after_coming_back = self.problem_page.problem_content
|
||||
after_meta = self.problem_page.problem_meta
|
||||
|
||||
self.assertEqual(problem1_content_before_switch, problem1_content_after_coming_back)
|
||||
self.assertEqual(before_meta, after_meta)
|
||||
|
||||
def test_perform_problem_save_and_navigate(self):
|
||||
"""
|
||||
Scenario:
|
||||
I go to sequential position 1
|
||||
Facing problem1, I select 'choice_1'
|
||||
Then I click save button
|
||||
Then I go to sequential position 2
|
||||
Then I came back to sequential position 1 again
|
||||
Facing problem1, I observe the problem1 content is not
|
||||
outdated before and after sequence navigation
|
||||
"""
|
||||
# Go to sequential position 1 and assert that we are on problem 1.
|
||||
self.go_to_tab_and_assert_problem(1, self.problem1_name)
|
||||
|
||||
# Update problem 1's content state by clicking save button.
|
||||
self.problem_page.click_choice('choice_choice_1')
|
||||
self.problem_page.click_save()
|
||||
self.problem_page.wait_for_save_notification()
|
||||
|
||||
# Save problem 1's content state as we're about to switch units in the sequence.
|
||||
problem1_content_before_switch = self.problem_page.problem_input_content
|
||||
before_meta = self.problem_page.problem_meta
|
||||
|
||||
# Go to sequential position 2 and assert that we are on problem 2.
|
||||
self.go_to_tab_and_assert_problem(2, self.problem2_name)
|
||||
|
||||
self.problem_page.wait_for_expected_status('span.unanswered', 'unanswered')
|
||||
|
||||
# Come back to our original unit in the sequence and assert that the content hasn't changed.
|
||||
self.go_to_tab_and_assert_problem(1, self.problem1_name)
|
||||
problem1_content_after_coming_back = self.problem_page.problem_input_content
|
||||
after_meta = self.problem_page.problem_meta
|
||||
|
||||
self.assertIn(problem1_content_after_coming_back, problem1_content_before_switch)
|
||||
self.assertEqual(before_meta, after_meta)
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class SubsectionHiddenAfterDueDateTest(UniqueCourseTest):
|
||||
"""
|
||||
Tests the "hide after due date" setting for
|
||||
subsections.
|
||||
"""
|
||||
USERNAME = "STUDENT_TESTER"
|
||||
EMAIL = "student101@example.com"
|
||||
|
||||
def setUp(self):
|
||||
super(SubsectionHiddenAfterDueDateTest, self).setUp()
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
self.logout_page = LogoutPage(self.browser)
|
||||
|
||||
self.studio_course_outline = StudioCourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
# Install a course with sections/problems, tabs, updates, and handouts
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 1').add_children(
|
||||
create_multiple_choice_problem('Test Problem 1')
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
self.progress_page = ProgressPage(self.browser, self.course_id)
|
||||
self._setup_subsection()
|
||||
|
||||
# Auto-auth register for the course.
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, False, self.course_id)
|
||||
|
||||
def _setup_subsection(self):
|
||||
"""
|
||||
Helper to set up a problem subsection as staff, then take
|
||||
it as a student.
|
||||
"""
|
||||
self.logout_page.visit()
|
||||
auto_auth(self.browser, "STAFF_TESTER", "staff101@example.com", True, self.course_id)
|
||||
self.studio_course_outline.visit()
|
||||
self.studio_course_outline.open_subsection_settings_dialog()
|
||||
|
||||
self.studio_course_outline.select_visibility_tab()
|
||||
self.studio_course_outline.make_subsection_hidden_after_due_date()
|
||||
|
||||
self.logout_page.visit()
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, False, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
|
||||
self.logout_page.visit()
|
||||
|
||||
def test_subsecton_hidden_after_due_date(self):
|
||||
"""
|
||||
Given that I am a staff member on the subsection settings section
|
||||
And I select the advanced settings tab
|
||||
When I Make the subsection hidden after its due date.
|
||||
And I login as a student.
|
||||
And visit the subsection in the courseware as a verified student.
|
||||
Then I am able to see the subsection
|
||||
And when I visit the progress page
|
||||
Then I should be able to see my grade on the progress page
|
||||
When I log in as staff
|
||||
And I make the subsection due in the past so that the current date is past its due date
|
||||
And I log in as a student
|
||||
And I visit the subsection in the courseware
|
||||
Then the subsection should be hidden with a message that its due date has passed
|
||||
And when I visit the progress page
|
||||
Then I should be able to see my grade on the progress page
|
||||
"""
|
||||
self.logout_page.visit()
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, False, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.assertFalse(self.courseware_page.content_hidden_past_due_date())
|
||||
|
||||
self.progress_page.visit()
|
||||
self.assertEqual(self.progress_page.scores('Test Section 1', 'Test Subsection 1'), [(0, 1)])
|
||||
|
||||
self.logout_page.visit()
|
||||
auto_auth(self.browser, "STAFF_TESTER", "staff101@example.com", True, self.course_id)
|
||||
self.studio_course_outline.visit()
|
||||
last_week = (datetime.today() - timedelta(days=7)).strftime("%m/%d/%Y")
|
||||
self.studio_course_outline.change_problem_due_date(last_week)
|
||||
|
||||
self.logout_page.visit()
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, False, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.assertTrue(self.courseware_page.content_hidden_past_due_date())
|
||||
|
||||
self.progress_page.visit()
|
||||
self.assertEqual(self.progress_page.scores('Test Section 1', 'Test Subsection 1'), [(0, 1)])
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class CompletionTestCase(UniqueCourseTest, EventsTestMixin):
|
||||
"""
|
||||
Test the completion on view functionality.
|
||||
"""
|
||||
USERNAME = "STUDENT_TESTER"
|
||||
EMAIL = "student101@example.com"
|
||||
COMPLETION_BY_VIEWING_DELAY_MS = '1000'
|
||||
|
||||
def setUp(self):
|
||||
super(CompletionTestCase, self).setUp()
|
||||
|
||||
self.studio_course_outline = StudioCourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
# Install a course with sections/problems, tabs, updates, and handouts
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
|
||||
self.html_1_block = XBlockFixtureDesc('html', 'html 1', data="<html>html 1 dummy body</html>")
|
||||
self.problem_1_block = XBlockFixtureDesc(
|
||||
'problem', 'Test Problem 1', data='<problem>problem 1 dummy body</problem>'
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 1,1').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit 1,1,1').add_children(
|
||||
XBlockFixtureDesc('html', 'html 1', data="<html>html 1 dummy body</html>"),
|
||||
XBlockFixtureDesc(
|
||||
'html', 'html 2',
|
||||
data=("<html>html 2 dummy body</html>" * 100) + "<span id='html2-end'>End</span>",
|
||||
),
|
||||
XBlockFixtureDesc('problem', 'Test Problem 1', data='<problem>problem 1 dummy body</problem>'),
|
||||
),
|
||||
XBlockFixtureDesc('vertical', 'Test Unit 1,1,2').add_children(
|
||||
XBlockFixtureDesc('html', 'html 1', data="<html>html 1 dummy body</html>"),
|
||||
XBlockFixtureDesc('problem', 'Test Problem 1', data='<problem>problem 1 dummy body</problem>'),
|
||||
),
|
||||
XBlockFixtureDesc('vertical', 'Test Unit 1,1,2').add_children(
|
||||
self.html_1_block,
|
||||
self.problem_1_block,
|
||||
),
|
||||
),
|
||||
),
|
||||
).install()
|
||||
|
||||
# Auto-auth register for the course.
|
||||
AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL,
|
||||
course_id=self.course_id, staff=False).visit()
|
||||
@@ -1,199 +0,0 @@
|
||||
"""
|
||||
Test courseware search
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.common.logout import LogoutPage
|
||||
from common.test.acceptance.pages.common.utils import click_css
|
||||
from common.test.acceptance.pages.lms.course_home import CourseHomePage
|
||||
from common.test.acceptance.pages.lms.courseware_search import CoursewareSearchPage
|
||||
from common.test.acceptance.pages.studio.container import ContainerPage
|
||||
from common.test.acceptance.pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
|
||||
from common.test.acceptance.pages.studio.utils import add_html_component, type_in_codemirror
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest, remove_file
|
||||
|
||||
|
||||
class CoursewareSearchTest(UniqueCourseTest):
|
||||
"""
|
||||
Test courseware search.
|
||||
"""
|
||||
USERNAME = 'STUDENT_TESTER'
|
||||
EMAIL = 'student101@example.com'
|
||||
|
||||
STAFF_USERNAME = "STAFF_TESTER"
|
||||
STAFF_EMAIL = "staff101@example.com"
|
||||
|
||||
HTML_CONTENT = """
|
||||
Someday I'll wish upon a star
|
||||
And wake up where the clouds are far
|
||||
Behind me.
|
||||
Where troubles melt like lemon drops
|
||||
Away above the chimney tops
|
||||
That's where you'll find me.
|
||||
"""
|
||||
SEARCH_STRING = "chimney"
|
||||
EDITED_CHAPTER_NAME = "Section 2 - edited"
|
||||
EDITED_SEARCH_STRING = "edited"
|
||||
|
||||
TEST_INDEX_FILENAME = "test_root/index_file.dat"
|
||||
shard = 5
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create search page and course content to search
|
||||
"""
|
||||
# create test file in which index for this test will live
|
||||
with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
|
||||
json.dump({}, index_file)
|
||||
self.addCleanup(remove_file, self.TEST_INDEX_FILENAME)
|
||||
|
||||
super(CoursewareSearchTest, self).setUp()
|
||||
|
||||
self.course_home_page = CourseHomePage(self.browser, self.course_id)
|
||||
|
||||
self.studio_course_outline = StudioCourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Section 1').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Subsection 1')
|
||||
)
|
||||
).add_children(
|
||||
XBlockFixtureDesc('chapter', 'Section 2').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Subsection 2')
|
||||
)
|
||||
).install()
|
||||
|
||||
def _auto_auth(self, username, email, staff):
|
||||
"""
|
||||
Logout and login with given credentials.
|
||||
"""
|
||||
LogoutPage(self.browser).visit()
|
||||
AutoAuthPage(self.browser, username=username, email=email,
|
||||
course_id=self.course_id, staff=staff).visit()
|
||||
|
||||
def _studio_publish_content(self, section_index):
|
||||
"""
|
||||
Publish content on studio course page under specified section
|
||||
"""
|
||||
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
|
||||
self.studio_course_outline.visit()
|
||||
subsection = self.studio_course_outline.section_at(section_index).subsection_at(0)
|
||||
subsection.expand_subsection()
|
||||
unit = subsection.unit_at(0)
|
||||
unit.publish()
|
||||
|
||||
def _studio_edit_chapter_name(self, section_index):
|
||||
"""
|
||||
Edit chapter name on studio course page under specified section
|
||||
"""
|
||||
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
|
||||
self.studio_course_outline.visit()
|
||||
section = self.studio_course_outline.section_at(section_index)
|
||||
section.change_name(self.EDITED_CHAPTER_NAME)
|
||||
|
||||
def _studio_add_content(self, section_index):
|
||||
"""
|
||||
Add content on studio course page under specified section
|
||||
"""
|
||||
|
||||
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
|
||||
# create a unit in course outline
|
||||
self.studio_course_outline.visit()
|
||||
subsection = self.studio_course_outline.section_at(section_index).subsection_at(0)
|
||||
subsection.expand_subsection()
|
||||
subsection.add_unit()
|
||||
|
||||
# got to unit and create an HTML component and save (not publish)
|
||||
unit_page = ContainerPage(self.browser, None)
|
||||
unit_page.wait_for_page()
|
||||
add_html_component(unit_page, 0)
|
||||
unit_page.wait_for_element_presence('.edit-button', 'Edit button is visible')
|
||||
click_css(unit_page, '.edit-button', 0, require_notification=False)
|
||||
unit_page.wait_for_element_visibility('.modal-editor', 'Modal editor is visible')
|
||||
type_in_codemirror(unit_page, 0, self.HTML_CONTENT)
|
||||
click_css(unit_page, '.action-save', 0)
|
||||
|
||||
def _studio_reindex(self):
|
||||
"""
|
||||
Reindex course content on studio course page
|
||||
"""
|
||||
|
||||
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
|
||||
self.studio_course_outline.visit()
|
||||
self.studio_course_outline.start_reindex()
|
||||
self.studio_course_outline.wait_for_ajax()
|
||||
|
||||
def _search_for_content(self, search_term):
|
||||
"""
|
||||
Login and search for specific content
|
||||
|
||||
Arguments:
|
||||
search_term - term to be searched for
|
||||
|
||||
Returns:
|
||||
(bool) True if search term is found in resulting content; False if not found
|
||||
"""
|
||||
self._auto_auth(self.USERNAME, self.EMAIL, False)
|
||||
self.course_home_page.visit()
|
||||
course_search_results_page = self.course_home_page.search_for_term(search_term)
|
||||
if len(course_search_results_page.search_results.html) > 0:
|
||||
search_string = course_search_results_page.search_results.html[0]
|
||||
else:
|
||||
search_string = ""
|
||||
return search_term in search_string
|
||||
|
||||
# TODO: TNL-6546: Remove usages of sidebar search
|
||||
def _search_for_content_in_sidebar(self, search_term, perform_auto_auth=True):
|
||||
"""
|
||||
Login and search for specific content in the legacy sidebar search
|
||||
Arguments:
|
||||
search_term - term to be searched for
|
||||
perform_auto_auth - if False, skip auto_auth call.
|
||||
Returns:
|
||||
(bool) True if search term is found in resulting content; False if not found
|
||||
"""
|
||||
if perform_auto_auth:
|
||||
self._auto_auth(self.USERNAME, self.EMAIL, False)
|
||||
self.courseware_search_page = CoursewareSearchPage(self.browser, self.course_id)
|
||||
self.courseware_search_page.visit()
|
||||
self.courseware_search_page.search_for_term(search_term)
|
||||
return search_term in self.courseware_search_page.search_results.html[0]
|
||||
|
||||
def test_search(self):
|
||||
"""
|
||||
Make sure that you can search for something.
|
||||
"""
|
||||
|
||||
# Create content in studio without publishing.
|
||||
self._studio_add_content(0)
|
||||
|
||||
# Do a search, there should be no results shown.
|
||||
self.assertFalse(self._search_for_content(self.SEARCH_STRING))
|
||||
|
||||
# Do a search in the legacy sidebar, there should be no results shown.
|
||||
self.assertFalse(self._search_for_content_in_sidebar(self.SEARCH_STRING, False))
|
||||
|
||||
# Publish in studio to trigger indexing.
|
||||
self._studio_publish_content(0)
|
||||
|
||||
# Do the search again, this time we expect results.
|
||||
self.assertTrue(self._search_for_content(self.SEARCH_STRING))
|
||||
|
||||
# Do the search again in the legacy sidebar, this time we expect results.
|
||||
self.assertTrue(self._search_for_content_in_sidebar(self.SEARCH_STRING, False))
|
||||
@@ -3,64 +3,17 @@
|
||||
End-to-end tests for the main LMS Dashboard (aka, Student Dashboard).
|
||||
"""
|
||||
|
||||
|
||||
import datetime
|
||||
import re
|
||||
import six
|
||||
from six.moves.urllib.parse import unquote
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.course_home import CourseHomePage
|
||||
from common.test.acceptance.pages.lms.dashboard import DashboardPage
|
||||
from common.test.acceptance.pages.lms.problem import ProblemPage
|
||||
from common.test.acceptance.pages.lms.staff_view import StaffPreviewPage
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest, generate_course_key
|
||||
|
||||
DEFAULT_SHORT_DATE_FORMAT = u'{dt:%b} {dt.day}, {dt.year}'
|
||||
TEST_DATE_FORMAT = u'{dt:%b} {dt.day}, {dt.year} {dt.hour:02}:{dt.minute:02}'
|
||||
|
||||
|
||||
class BaseLmsDashboardTest(UniqueCourseTest):
|
||||
""" Base test suite for the LMS Student Dashboard """
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Initializes the components (page objects, courses, users) for this test suite
|
||||
"""
|
||||
# Some parameters are provided by the parent setUp() routine, such as the following:
|
||||
# self.course_id, self.course_info, self.unique_id
|
||||
super(BaseLmsDashboardTest, self).setUp()
|
||||
|
||||
# Load page objects for use by the tests
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
|
||||
# Configure some aspects of the test course and install the settings into the course
|
||||
self.course_fixture = CourseFixture(
|
||||
self.course_info["org"],
|
||||
self.course_info["number"],
|
||||
self.course_info["run"],
|
||||
self.course_info["display_name"],
|
||||
)
|
||||
self.course_fixture.add_advanced_settings({
|
||||
u"social_sharing_url": {u"value": "http://custom/course/url"}
|
||||
})
|
||||
self.course_fixture.install()
|
||||
|
||||
self.username = "test_{uuid}".format(uuid=self.unique_id[0:6])
|
||||
self.email = "{user}@example.com".format(user=self.username)
|
||||
|
||||
# Create the test user, register them for the course, and authenticate
|
||||
AutoAuthPage(
|
||||
self.browser,
|
||||
username=self.username,
|
||||
email=self.email,
|
||||
course_id=self.course_id
|
||||
).visit()
|
||||
|
||||
# Navigate the authenticated, enrolled user to the dashboard page and get testing!
|
||||
self.dashboard_page.visit()
|
||||
|
||||
|
||||
class BaseLmsDashboardTestMultiple(UniqueCourseTest):
|
||||
""" Base test suite for the LMS Student Dashboard with Multiple Courses"""
|
||||
|
||||
@@ -162,272 +115,6 @@ class BaseLmsDashboardTestMultiple(UniqueCourseTest):
|
||||
self.dashboard_page.visit()
|
||||
|
||||
|
||||
class LmsDashboardPageTest(BaseLmsDashboardTest):
|
||||
""" Test suite for the LMS Student Dashboard page """
|
||||
shard = 9
|
||||
|
||||
def setUp(self):
|
||||
super(LmsDashboardPageTest, self).setUp()
|
||||
|
||||
# now datetime for usage in tests
|
||||
self.now = datetime.datetime.now()
|
||||
|
||||
def test_dashboard_course_listings(self):
|
||||
"""
|
||||
Perform a general validation of the course listings section
|
||||
"""
|
||||
course_listings = self.dashboard_page.get_course_listings()
|
||||
self.assertEqual(len(course_listings), 1)
|
||||
|
||||
def test_dashboard_social_sharing_feature(self):
|
||||
"""
|
||||
Validate the behavior of the social sharing feature
|
||||
"""
|
||||
twitter_widget = self.dashboard_page.get_course_social_sharing_widget('twitter')
|
||||
twitter_url = ("https://twitter.com/intent/tweet?text=Testing+feature%3A%20http%3A%2F%2Fcustom%2Fcourse%2Furl"
|
||||
"%3Futm_campaign%3Dsocial-sharing-db%26utm_medium%3Dsocial%26utm_source%3Dtwitter")
|
||||
self.assertEqual(twitter_widget.attrs('title')[0], 'Share on Twitter')
|
||||
self.assertEqual(twitter_widget.attrs('data-tooltip')[0], 'Share on Twitter')
|
||||
self.assertEqual(twitter_widget.attrs('target')[0], '_blank')
|
||||
self._assert_social_url(twitter_widget.attrs('href')[0], unquote(twitter_url), r"\'(.*?)\'\,")
|
||||
self._assert_social_url(twitter_widget.attrs('onclick')[0], unquote(twitter_url), r"\'(.*?)\'\,")
|
||||
|
||||
facebook_widget = self.dashboard_page.get_course_social_sharing_widget('facebook')
|
||||
facebook_url = ("https://www.facebook.com/sharer/sharer.php?u=http%3A%2F%2Fcustom%2Fcourse%2Furl%3F"
|
||||
"utm_campaign%3Dsocial-sharing-db%26utm_medium%3Dsocial%26utm_source%3Dfacebook&"
|
||||
"quote=I%27m+taking+Test+Course")
|
||||
self.assertEqual(facebook_widget.attrs('title')[0], 'Share on Facebook')
|
||||
self.assertEqual(facebook_widget.attrs('data-tooltip')[0], 'Share on Facebook')
|
||||
self.assertEqual(facebook_widget.attrs('target')[0], '_blank')
|
||||
self._assert_social_url(facebook_widget.attrs('onclick')[0], unquote(facebook_url), r"\'(.*?);")
|
||||
self._assert_social_url(facebook_widget.attrs('href')[0], unquote(facebook_url), r"^(.*)\;")
|
||||
|
||||
def _assert_social_url(self, url, expected_url, pattern):
|
||||
"""
|
||||
will remove byte characters from specific query parameter
|
||||
"""
|
||||
url = unquote(url)
|
||||
social_url_search = re.search(pattern, url)
|
||||
url_split = (social_url_search.group(1) if social_url_search else url).split('?')
|
||||
query_parameters = url_split[2].split('&')
|
||||
urls = url_split[:2] + query_parameters
|
||||
for query_parameter in urls:
|
||||
self.assertIn(query_parameter.strip("'"), expected_url)
|
||||
|
||||
def test_ended_course_date(self):
|
||||
"""
|
||||
Scenario:
|
||||
Course Date should have the format 'Ended - Sep 23, 2015'
|
||||
if the course on student dashboard has ended.
|
||||
As a Student,
|
||||
Given that I have enrolled to a course
|
||||
And the course has ended in the past
|
||||
When I visit dashboard page
|
||||
Then the course date should have the following format "Ended - %b %d, %Y" e.g. "Ended - Sep 23, 2015"
|
||||
"""
|
||||
course_start_date = datetime.datetime(1970, 1, 1)
|
||||
course_end_date = self.now - datetime.timedelta(days=90)
|
||||
|
||||
self.course_fixture.add_course_details({
|
||||
'start_date': course_start_date,
|
||||
'end_date': course_end_date
|
||||
})
|
||||
self.course_fixture.configure_course()
|
||||
|
||||
end_date = DEFAULT_SHORT_DATE_FORMAT.format(dt=course_end_date)
|
||||
expected_course_date = u"Ended - {end_date}".format(end_date=end_date)
|
||||
|
||||
# reload the page for changes to course date changes to appear in dashboard
|
||||
self.dashboard_page.visit()
|
||||
|
||||
course_date = self.dashboard_page.get_course_date()
|
||||
|
||||
# Test that proper course date with 'ended' message is displayed if a course has already ended
|
||||
self.assertEqual(course_date, expected_course_date)
|
||||
|
||||
def test_running_course_date(self):
|
||||
"""
|
||||
Scenario:
|
||||
Course Date should have the format 'Started - Sep 23, 2015'
|
||||
if the course on student dashboard is running.
|
||||
As a Student,
|
||||
Given that I have enrolled to a course
|
||||
And the course has started
|
||||
And the course is in progress
|
||||
When I visit dashboard page
|
||||
Then the course date should have the following format "Started - %b %d, %Y" e.g. "Started - Sep 23, 2015"
|
||||
"""
|
||||
course_start_date = datetime.datetime(1970, 1, 1)
|
||||
course_end_date = self.now + datetime.timedelta(days=90)
|
||||
|
||||
self.course_fixture.add_course_details({
|
||||
'start_date': course_start_date,
|
||||
'end_date': course_end_date
|
||||
})
|
||||
self.course_fixture.configure_course()
|
||||
|
||||
start_date = DEFAULT_SHORT_DATE_FORMAT.format(dt=course_start_date)
|
||||
expected_course_date = u"Started - {start_date}".format(start_date=start_date)
|
||||
|
||||
# reload the page for changes to course date changes to appear in dashboard
|
||||
self.dashboard_page.visit()
|
||||
|
||||
course_date = self.dashboard_page.get_course_date()
|
||||
|
||||
# Test that proper course date with 'started' message is displayed if a course is in running state
|
||||
self.assertEqual(course_date, expected_course_date)
|
||||
|
||||
def test_future_course_date(self):
|
||||
"""
|
||||
Scenario:
|
||||
Course Date should have the format 'Starts - Sep 23, 2015'
|
||||
if the course on student dashboard starts in future.
|
||||
As a Student,
|
||||
Given that I have enrolled to a course
|
||||
And the course starts in future
|
||||
And the course does not start within 5 days
|
||||
When I visit dashboard page
|
||||
Then the course date should have the following format "Starts - %b %d, %Y" e.g. "Starts - Sep 23, 2015"
|
||||
"""
|
||||
course_start_date = self.now + datetime.timedelta(days=30)
|
||||
course_end_date = self.now + datetime.timedelta(days=365)
|
||||
|
||||
self.course_fixture.add_course_details({
|
||||
'start_date': course_start_date,
|
||||
'end_date': course_end_date
|
||||
})
|
||||
self.course_fixture.configure_course()
|
||||
|
||||
start_date = DEFAULT_SHORT_DATE_FORMAT.format(dt=course_start_date)
|
||||
expected_course_date = u"Starts - {start_date}".format(start_date=start_date)
|
||||
|
||||
# reload the page for changes to course date changes to appear in dashboard
|
||||
self.dashboard_page.visit()
|
||||
|
||||
course_date = self.dashboard_page.get_course_date()
|
||||
|
||||
# Test that proper course date with 'starts' message is displayed if a course is about to start in future,
|
||||
# and course does not start within 5 days
|
||||
self.assertEqual(course_date, expected_course_date)
|
||||
|
||||
def test_near_future_course_date(self):
|
||||
"""
|
||||
Scenario:
|
||||
Course Date should have the format 'Starts - Wednesday at 5am UTC'
|
||||
if the course on student dashboard starts within 5 days.
|
||||
As a Student,
|
||||
Given that I have enrolled to a course
|
||||
And the course starts within 5 days
|
||||
When I visit dashboard page
|
||||
Then the course date should have the following format "Starts - %A at %-I%P UTC"
|
||||
e.g. "Starts - Wednesday at 5am UTC"
|
||||
"""
|
||||
course_start_date = self.now + datetime.timedelta(days=2)
|
||||
course_end_date = self.now + datetime.timedelta(days=365)
|
||||
|
||||
self.course_fixture.add_course_details({
|
||||
'start_date': course_start_date,
|
||||
'end_date': course_end_date
|
||||
})
|
||||
self.course_fixture.configure_course()
|
||||
|
||||
start_date = TEST_DATE_FORMAT.format(dt=course_start_date)
|
||||
expected_course_date = u"Starts - {start_date} UTC".format(start_date=start_date)
|
||||
|
||||
# reload the page for changes to course date changes to appear in dashboard
|
||||
self.dashboard_page.visit()
|
||||
|
||||
course_date = self.dashboard_page.get_course_date()
|
||||
|
||||
# Test that proper course date with 'starts' message is displayed if a course is about to start in future,
|
||||
# and course starts within 5 days
|
||||
self.assertEqual(course_date, expected_course_date)
|
||||
|
||||
def test_advertised_start_date(self):
|
||||
"""
|
||||
Scenario:
|
||||
Course Date should be advertised start date
|
||||
if the course on student dashboard has `Course Advertised Start` set.
|
||||
|
||||
As a Student,
|
||||
Given that I have enrolled to a course
|
||||
And the course has `Course Advertised Start` set.
|
||||
When I visit dashboard page
|
||||
Then the advertised start date should be displayed rather course start date"
|
||||
"""
|
||||
course_start_date = self.now + datetime.timedelta(days=2)
|
||||
course_advertised_start = "Winter 2018"
|
||||
|
||||
self.course_fixture.add_course_details({
|
||||
'start_date': course_start_date,
|
||||
})
|
||||
self.course_fixture.configure_course()
|
||||
|
||||
self.course_fixture.add_advanced_settings({
|
||||
u"advertised_start": {u"value": course_advertised_start}
|
||||
})
|
||||
self.course_fixture._add_advanced_settings()
|
||||
|
||||
expected_course_date = u"Starts - {start_date}".format(start_date=course_advertised_start)
|
||||
|
||||
self.dashboard_page.visit()
|
||||
course_date = self.dashboard_page.get_course_date()
|
||||
|
||||
self.assertEqual(course_date, expected_course_date)
|
||||
|
||||
def test_profile_img_alt_empty(self):
|
||||
"""
|
||||
Validate value of profile image alt attribue is null
|
||||
"""
|
||||
profile_img = self.dashboard_page.get_profile_img()
|
||||
self.assertEqual(profile_img.attrs('alt')[0], '')
|
||||
|
||||
|
||||
class LmsDashboardCourseUnEnrollDialogMessageTest(BaseLmsDashboardTestMultiple):
|
||||
"""
|
||||
Class to test lms student dashboard unenroll dialog messages.
|
||||
"""
|
||||
shard = 23
|
||||
|
||||
def test_audit_course_run_unenroll_dialog_msg(self):
|
||||
"""
|
||||
Validate unenroll dialog message when user clicks unenroll button for a audit course
|
||||
"""
|
||||
|
||||
self.dashboard_page.visit()
|
||||
dialog_message = self.dashboard_page.view_course_unenroll_dialog_message(str(self.course_keys['A']))
|
||||
course_number = self.courses['A']['number']
|
||||
course_name = self.courses['A']['display_name']
|
||||
|
||||
expected_track_message = u'Are you sure you want to unenroll from' + \
|
||||
u' <span id="unenroll_course_name">' + course_name + u'</span>' + \
|
||||
u' (<span id="unenroll_course_number">' + course_number + u'</span>)?'
|
||||
|
||||
self.assertEqual(dialog_message['track-info'][0], expected_track_message)
|
||||
|
||||
def test_verified_course_run_unenroll_dialog_msg(self):
|
||||
"""
|
||||
Validate unenroll dialog message when user clicks unenroll button for a verified course passed refund
|
||||
deadline
|
||||
"""
|
||||
|
||||
self.dashboard_page.visit()
|
||||
dialog_message = self.dashboard_page.view_course_unenroll_dialog_message(str(self.course_keys['B']))
|
||||
course_number = self.courses['B']['number']
|
||||
course_name = self.courses['B']['display_name']
|
||||
cert_long_name = self.courses['B']['cert_name_long']
|
||||
|
||||
expected_track_message = u'Are you sure you want to unenroll from the verified' + \
|
||||
u' <span id="unenroll_cert_name">' + cert_long_name + u'</span>' + \
|
||||
u' track of <span id="unenroll_course_name">' + course_name + u'</span>' + \
|
||||
u' (<span id="unenroll_course_number">' + course_number + u'</span>)?'
|
||||
|
||||
expected_refund_message = u'The refund deadline for this course has passed,so you will not receive a refund.'
|
||||
|
||||
self.assertEqual(dialog_message['track-info'][0], expected_track_message)
|
||||
self.assertEqual(dialog_message['refund-info'][0], expected_refund_message)
|
||||
|
||||
|
||||
class LmsDashboardA11yTest(BaseLmsDashboardTestMultiple):
|
||||
"""
|
||||
Class to test lms student dashboard accessibility.
|
||||
@@ -450,49 +137,3 @@ class LmsDashboardA11yTest(BaseLmsDashboardTestMultiple):
|
||||
course_listings = self.dashboard_page.get_courses()
|
||||
self.assertEqual(len(course_listings), 3)
|
||||
self.dashboard_page.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
|
||||
class TestMasqueradeAndSwitchCourse(BaseLmsDashboardTestMultiple):
|
||||
"""
|
||||
Class to test lms dashboard accessibility of courses when masquerading as learner.
|
||||
"""
|
||||
|
||||
def test_masquerade_and_switch_course(self):
|
||||
"""
|
||||
Scenario:
|
||||
Staff user should be able to access other courses after
|
||||
masquerading as student in one course
|
||||
|
||||
As Staff user, Select a course
|
||||
When I click to change view from Staff to Learner
|
||||
Then the first subsection from course outline should be visible as Learner
|
||||
When I click to select a different course
|
||||
Then the first subsection from new course outline should be visible as Staff
|
||||
"""
|
||||
AutoAuthPage(
|
||||
self.browser,
|
||||
username=self.username,
|
||||
email=self.email,
|
||||
staff=True
|
||||
).visit()
|
||||
self.dashboard_page.visit()
|
||||
|
||||
section_title = 'Test Section 1'
|
||||
subsection_title = 'Test Subsection 1,1'
|
||||
course_page = CourseHomePage(self.browser, str(self.course_keys['A']))
|
||||
course_page.visit()
|
||||
|
||||
problem_name = u'Test Problem 1'
|
||||
|
||||
staff_page = StaffPreviewPage(self.browser)
|
||||
staff_page.set_staff_view_mode('Learner')
|
||||
|
||||
course_page.outline.go_to_section(section_title, subsection_title)
|
||||
self.assertEqual(staff_page.staff_view_mode, 'Learner')
|
||||
self.assertEqual(ProblemPage(self.browser).problem_name, problem_name)
|
||||
|
||||
course_page.course_id = str(self.course_keys['B'])
|
||||
course_page.visit()
|
||||
course_page.outline.go_to_section(section_title, subsection_title)
|
||||
self.assertNotEqual(staff_page.staff_view_mode, 'Learner')
|
||||
self.assertEqual(ProblemPage(self.browser).problem_name, problem_name)
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Bok choy acceptance tests for Entrance exams in the LMS
|
||||
"""
|
||||
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest
|
||||
|
||||
|
||||
class EntranceExamTest(UniqueCourseTest):
|
||||
"""
|
||||
Base class for tests of Entrance Exams in the LMS.
|
||||
"""
|
||||
USERNAME = "joe_student"
|
||||
EMAIL = "joe@example.com"
|
||||
|
||||
def setUp(self):
|
||||
super(EntranceExamTest, self).setUp()
|
||||
|
||||
self.xqueue_grade_response = None
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
|
||||
# Install a course with a hierarchy and problems
|
||||
course_fixture = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name'],
|
||||
settings={
|
||||
'entrance_exam_enabled': 'true',
|
||||
'entrance_exam_minimum_score_pct': '50'
|
||||
}
|
||||
)
|
||||
|
||||
problem = self.get_problem()
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(problem)
|
||||
)
|
||||
).install()
|
||||
|
||||
entrance_exam_subsection = None
|
||||
outline = course_fixture.studio_course_outline_as_json
|
||||
for child in outline['child_info']['children']:
|
||||
if child.get('display_name') == "Entrance Exam":
|
||||
entrance_exam_subsection = child['child_info']['children'][0]
|
||||
|
||||
if entrance_exam_subsection:
|
||||
course_fixture.create_xblock(entrance_exam_subsection['id'], problem)
|
||||
|
||||
# Auto-auth register for the course.
|
||||
AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL,
|
||||
course_id=self.course_id, staff=False).visit()
|
||||
|
||||
def get_problem(self):
|
||||
""" Subclasses should override this to complete the fixture """
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class EntranceExamPassTest(EntranceExamTest):
|
||||
"""
|
||||
Tests the scenario when a student passes entrance exam.
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Create a multiple choice problem
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<multiplechoiceresponse>
|
||||
<label>What is height of eiffel tower without the antenna?.</label>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">324 meters<choicehint>Antenna is 24 meters high</choicehint></choice>
|
||||
<choice correct="true">300 meters</choice>
|
||||
<choice correct="false">224 meters</choice>
|
||||
<choice correct="false">400 meters</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'HEIGHT OF EIFFEL TOWER', data=xml)
|
||||
@@ -1,279 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
End-to-end tests for the gating feature.
|
||||
"""
|
||||
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.common.logout import LogoutPage
|
||||
from common.test.acceptance.pages.lms.course_home import CourseHomePage
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.pages.lms.problem import ProblemPage
|
||||
from common.test.acceptance.pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest
|
||||
|
||||
|
||||
class GatingTest(UniqueCourseTest):
|
||||
"""
|
||||
Test gating feature in LMS.
|
||||
"""
|
||||
STAFF_USERNAME = "STAFF_TESTER"
|
||||
STAFF_EMAIL = "staff101@example.com"
|
||||
|
||||
STUDENT_USERNAME = "STUDENT_TESTER"
|
||||
STUDENT_EMAIL = "student101@example.com"
|
||||
|
||||
shard = 23
|
||||
|
||||
def setUp(self):
|
||||
super(GatingTest, self).setUp()
|
||||
|
||||
self.logout_page = LogoutPage(self.browser)
|
||||
self.course_home_page = CourseHomePage(self.browser, self.course_id)
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
self.studio_course_outline = StudioCourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<p>What is height of eiffel tower without the antenna?.</p>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup label="What is height of eiffel tower without the antenna?" type="MultipleChoice">
|
||||
<choice correct="false">324 meters<choicehint>Antenna is 24 meters high</choicehint></choice>
|
||||
<choice correct="true">300 meters</choice>
|
||||
<choice correct="false">224 meters</choice>
|
||||
<choice correct="false">400 meters</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
self.problem1 = XBlockFixtureDesc('problem', 'HEIGHT OF EIFFEL TOWER', data=xml)
|
||||
|
||||
# Install a course with sections/problems
|
||||
course_fixture = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
course_fixture.add_advanced_settings({
|
||||
"enable_subsection_gating": {"value": "true"}, 'enable_proctored_exams': {"value": "true"}
|
||||
})
|
||||
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 1').add_children(
|
||||
self.problem1
|
||||
),
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 2').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Problem 2')
|
||||
),
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 3').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Problem 3')
|
||||
),
|
||||
|
||||
)
|
||||
).install()
|
||||
|
||||
def _auto_auth(self, username, email, staff):
|
||||
"""
|
||||
Logout and login with given credentials.
|
||||
"""
|
||||
self.logout_page.visit()
|
||||
AutoAuthPage(self.browser, username=username, email=email,
|
||||
course_id=self.course_id, staff=staff).visit()
|
||||
|
||||
def _setup_prereq(self):
|
||||
"""
|
||||
Make the first subsection a prerequisite
|
||||
"""
|
||||
# Login as staff
|
||||
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
|
||||
|
||||
# Make the first subsection a prerequisite
|
||||
self.studio_course_outline.visit()
|
||||
self.studio_course_outline.open_subsection_settings_dialog(0)
|
||||
self.studio_course_outline.select_advanced_tab(desired_item='gated_content')
|
||||
self.studio_course_outline.make_gating_prerequisite()
|
||||
|
||||
def _setup_gated_subsection(self, subsection_index=1):
|
||||
"""
|
||||
Gate the given indexed subsection on the first subsection
|
||||
"""
|
||||
# Login as staff
|
||||
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
|
||||
|
||||
# Gate the second subsection based on the score achieved in the first subsection
|
||||
self.studio_course_outline.visit()
|
||||
self.studio_course_outline.open_subsection_settings_dialog(subsection_index)
|
||||
self.studio_course_outline.select_advanced_tab(desired_item='gated_content')
|
||||
self.studio_course_outline.add_prerequisite_to_subsection("80", "")
|
||||
|
||||
def _fulfill_prerequisite(self):
|
||||
"""
|
||||
Fulfill the prerequisite needed to see gated content
|
||||
"""
|
||||
problem_page = ProblemPage(self.browser)
|
||||
self.assertEqual(problem_page.wait_for_page().problem_name, 'HEIGHT OF EIFFEL TOWER')
|
||||
problem_page.click_choice('choice_1')
|
||||
problem_page.click_submit()
|
||||
|
||||
def test_subsection_gating_in_studio(self):
|
||||
"""
|
||||
Given that I am a staff member
|
||||
When I visit the course outline page in studio.
|
||||
And open the subsection edit dialog
|
||||
Then I can view all settings related to Gating
|
||||
And update those settings to gate a subsection
|
||||
"""
|
||||
self._setup_prereq()
|
||||
|
||||
# Assert settings are displayed correctly for a prerequisite subsection
|
||||
self.studio_course_outline.visit()
|
||||
self.studio_course_outline.open_subsection_settings_dialog(0)
|
||||
self.studio_course_outline.select_advanced_tab(desired_item='gated_content')
|
||||
self.assertTrue(self.studio_course_outline.gating_prerequisite_checkbox_is_visible())
|
||||
self.assertTrue(self.studio_course_outline.gating_prerequisite_checkbox_is_checked())
|
||||
self.assertFalse(self.studio_course_outline.gating_prerequisites_dropdown_is_visible())
|
||||
self.assertFalse(self.studio_course_outline.gating_prerequisite_min_score_is_visible())
|
||||
|
||||
self._setup_gated_subsection()
|
||||
|
||||
# Assert settings are displayed correctly for a gated subsection
|
||||
self.studio_course_outline.visit()
|
||||
self.studio_course_outline.open_subsection_settings_dialog(1)
|
||||
self.studio_course_outline.select_advanced_tab(desired_item='gated_content')
|
||||
self.assertTrue(self.studio_course_outline.gating_prerequisite_checkbox_is_visible())
|
||||
self.assertTrue(self.studio_course_outline.gating_prerequisites_dropdown_is_visible())
|
||||
self.assertTrue(self.studio_course_outline.gating_prerequisite_min_score_is_visible())
|
||||
|
||||
def test_gated_subsection_in_lms_for_student(self):
|
||||
"""
|
||||
Given that I am a student
|
||||
When I visit the LMS Courseware
|
||||
Then I can see a gated subsection
|
||||
The gated subsection should have a lock icon
|
||||
and be in the format: "<Subsection Title> (Prerequisite Required)"
|
||||
When I fulfill the gating Prerequisite
|
||||
Then I can see the gated subsection
|
||||
Now the gated subsection should have an unlock icon
|
||||
and screen readers should read the section as: "<Subsection Title> Unlocked"
|
||||
"""
|
||||
self._setup_prereq()
|
||||
self._setup_gated_subsection()
|
||||
|
||||
self._auto_auth(self.STUDENT_USERNAME, self.STUDENT_EMAIL, False)
|
||||
|
||||
self.course_home_page.visit()
|
||||
self.assertEqual(self.course_home_page.outline.num_subsections, 3)
|
||||
|
||||
# Fulfill prerequisite and verify that gated subsection is shown
|
||||
self.courseware_page.visit()
|
||||
self._fulfill_prerequisite()
|
||||
self.course_home_page.visit()
|
||||
self.assertEqual(self.course_home_page.outline.num_subsections, 3)
|
||||
|
||||
def test_gated_subsection_in_lms_for_staff(self):
|
||||
"""
|
||||
Given that I am a staff member
|
||||
When I visit the LMS Courseware
|
||||
Then I can see all gated subsections
|
||||
Displayed along with notification banners
|
||||
Then if I masquerade as a student
|
||||
Then I can see a gated subsection
|
||||
The gated subsection should have a lock icon
|
||||
and be in the format: "<Subsection Title> (Prerequisite Required)"
|
||||
"""
|
||||
self._setup_prereq()
|
||||
self._setup_gated_subsection()
|
||||
|
||||
self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
|
||||
|
||||
self.course_home_page.visit()
|
||||
self.assertEqual(self.course_home_page.preview.staff_view_mode, 'Staff')
|
||||
self.assertEqual(self.course_home_page.outline.num_subsections, 3)
|
||||
|
||||
# Click on gated section and check for banner
|
||||
self.course_home_page.outline.go_to_section('Test Section 1', 'Test Subsection 2')
|
||||
self.courseware_page.wait_for_page()
|
||||
self.assertTrue(self.courseware_page.has_banner())
|
||||
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.outline.go_to_section('Test Section 1', 'Test Subsection 1')
|
||||
self.courseware_page.wait_for_page()
|
||||
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.preview.set_staff_view_mode('Learner')
|
||||
self.course_home_page.wait_for_page()
|
||||
self.assertEqual(self.course_home_page.outline.num_subsections, 3)
|
||||
self.course_home_page.outline.go_to_section('Test Section 1', 'Test Subsection 1')
|
||||
self.courseware_page.wait_for_page()
|
||||
# banner displayed informing section is a prereq
|
||||
self.assertTrue(self.courseware_page.has_banner())
|
||||
|
||||
def test_gated_banner_before_special_exam(self):
|
||||
"""
|
||||
When a subsection with a prereq is a special
|
||||
exam, show the gating banner before starting
|
||||
the special exam.
|
||||
|
||||
Setup the course with a subsection having pre-req
|
||||
Subsection with pre-req is a special exam
|
||||
Go the LMS course outline page
|
||||
Click the special exam subsection
|
||||
The gated banner asking for completing
|
||||
prereqs should be visible
|
||||
Go to the required subsection
|
||||
Fulfill the requirements
|
||||
Visit the special exam subsection again
|
||||
The gated banner is not visible anymore
|
||||
and user can start the special exam
|
||||
"""
|
||||
|
||||
self._setup_prereq()
|
||||
|
||||
# Gating subsection 1 and making it a timed exam
|
||||
self._setup_gated_subsection()
|
||||
self.studio_course_outline.open_subsection_settings_dialog(1)
|
||||
self.studio_course_outline.select_advanced_tab()
|
||||
self.studio_course_outline.make_exam_timed()
|
||||
|
||||
# Gating subsection 2 and making it a proctored exam
|
||||
self._setup_gated_subsection(2)
|
||||
self.studio_course_outline.open_subsection_settings_dialog(2)
|
||||
self.studio_course_outline.select_advanced_tab()
|
||||
self.studio_course_outline.make_exam_proctored()
|
||||
|
||||
self._auto_auth(self.STUDENT_USERNAME, self.STUDENT_EMAIL, False)
|
||||
self.course_home_page.visit()
|
||||
|
||||
# Test gating banner before starting timed exam
|
||||
self.course_home_page.outline.go_to_section('Test Section 1', 'Test Subsection 2')
|
||||
self.assertTrue(self.courseware_page.is_gating_banner_visible())
|
||||
|
||||
# Test gating banner before proctored exams
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.outline.go_to_section('Test Section 1', 'Test Subsection 3')
|
||||
self.assertTrue(self.courseware_page.is_gating_banner_visible())
|
||||
|
||||
# Fulfill requirements
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.outline.go_to_section('Test Section 1', 'Test Subsection 1')
|
||||
self._fulfill_prerequisite()
|
||||
|
||||
# Banner is not visible anymore on timed exam sub-section
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.outline.go_to_section('Test Section 1', 'Test Subsection 2')
|
||||
self.assertFalse(self.courseware_page.is_gating_banner_visible())
|
||||
|
||||
# Banner is not visible on proctored exam subsection
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.outline.go_to_section('Test Section 1', 'Test Subsection 3')
|
||||
self.assertFalse(self.courseware_page.is_gating_banner_visible())
|
||||
@@ -1,90 +0,0 @@
|
||||
"""
|
||||
Test Help links in LMS
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture
|
||||
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage
|
||||
from common.test.acceptance.tests.discussion.helpers import CohortTestMixin
|
||||
from common.test.acceptance.tests.helpers import (
|
||||
assert_opened_help_link_is_correct,
|
||||
click_and_wait_for_window,
|
||||
url_for_help
|
||||
)
|
||||
from common.test.acceptance.tests.lms.test_lms_instructor_dashboard import BaseInstructorDashboardTest
|
||||
from common.test.acceptance.tests.studio.base_studio_test import ContainerBase
|
||||
from openedx.core.release import skip_unless_master
|
||||
|
||||
# @skip_unless_master is used throughout this file because on named release
|
||||
# branches, most work happens leading up to the first release on the branch, and
|
||||
# that is before the docs have been published. Tests that check readthedocs for
|
||||
# the right doc page will fail during this time, and it's just a big
|
||||
# distraction. Also, if we bork the docs, it's not the end of the world, and we
|
||||
# can fix it easily, so this is a good tradeoff.
|
||||
|
||||
|
||||
@skip_unless_master # See note at the top of the file.
|
||||
class TestCohortHelp(ContainerBase, CohortTestMixin):
|
||||
"""
|
||||
Tests help links in Cohort page
|
||||
"""
|
||||
shard = 2
|
||||
|
||||
def setUp(self, is_staff=True):
|
||||
super(TestCohortHelp, self).setUp(is_staff=is_staff)
|
||||
self.enable_cohorting(self.course_fixture)
|
||||
self.instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id)
|
||||
self.instructor_dashboard_page.visit()
|
||||
self.cohort_management = self.instructor_dashboard_page.select_cohort_management()
|
||||
|
||||
def verify_help_link(self, href):
|
||||
"""
|
||||
Verifies that help link is correct
|
||||
Arguments:
|
||||
href (str): Help url
|
||||
"""
|
||||
help_element = self.cohort_management.get_cohort_help_element()
|
||||
self.assertEqual(help_element.text, "What does this mean?")
|
||||
click_and_wait_for_window(self, help_element)
|
||||
assert_opened_help_link_is_correct(self, href)
|
||||
|
||||
def test_manual_cohort_help(self):
|
||||
"""
|
||||
Scenario: Help in 'What does it mean?' is correct when we create cohort manually.
|
||||
Given that I am at 'Cohort' tab of LMS instructor dashboard
|
||||
And I check 'Enable Cohorts'
|
||||
And I add cohort name it, choose Manual for Cohort Assignment Method and
|
||||
No content group for Associated Content Group and save the cohort
|
||||
Then you see the UI text "Learners are added to this cohort only when..."
|
||||
followed by "What does this mean" link.
|
||||
And I click "What does this mean" link then help link should end with
|
||||
course_features/cohorts/cohort_config.html#assign-learners-to-cohorts-manually
|
||||
"""
|
||||
self.cohort_management.add_cohort('cohort_name')
|
||||
|
||||
href = url_for_help(
|
||||
'course_author',
|
||||
'/course_features/cohorts/cohort_config.html#assign-learners-to-cohorts-manually',
|
||||
)
|
||||
self.verify_help_link(href)
|
||||
|
||||
def test_automatic_cohort_help(self):
|
||||
"""
|
||||
Scenario: Help in 'What does it mean?' is correct when we create cohort automatically.
|
||||
Given that I am at 'Cohort' tab of LMS instructor dashboard
|
||||
And I check 'Enable Cohorts'
|
||||
And I add cohort name it, choose Automatic for Cohort Assignment Method and
|
||||
No content group for Associated Content Group and save the cohort
|
||||
Then you see the UI text "Learners are added to this cohort automatically"
|
||||
followed by "What does this mean" link.
|
||||
And I click "What does this mean" link then help link should end with
|
||||
course_features/cohorts/cohorts_overview.html#all-automated-assignment
|
||||
"""
|
||||
|
||||
self.cohort_management.add_cohort('cohort_name', assignment_type='random')
|
||||
|
||||
href = url_for_help(
|
||||
'course_author',
|
||||
'/course_features/cohorts/cohorts_overview.html#all-automated-assignment',
|
||||
)
|
||||
self.verify_help_link(href)
|
||||
@@ -1,60 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
End-to-end tests for the LMS Index page (aka, Home page). Note that this is different than
|
||||
what students see @ edx.org because we redirect requests to a separate web application.
|
||||
"""
|
||||
|
||||
|
||||
import datetime
|
||||
|
||||
from common.test.acceptance.pages.lms.index import IndexPage
|
||||
from common.test.acceptance.tests.helpers import AcceptanceTest
|
||||
|
||||
|
||||
class BaseLmsIndexTest(AcceptanceTest):
|
||||
""" Base test suite for the LMS Index (Home) page """
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Initializes the components (page objects, courses, users) for this test suite
|
||||
"""
|
||||
# Some state is constructed by the parent setUp() routine
|
||||
super(BaseLmsIndexTest, self).setUp()
|
||||
|
||||
# Load page objects for use by the tests
|
||||
self.page = IndexPage(self.browser)
|
||||
|
||||
# Navigate to the index page and get testing!
|
||||
self.page.visit()
|
||||
|
||||
|
||||
class LmsIndexPageTest(BaseLmsIndexTest):
|
||||
""" Test suite for the LMS Index (Home) page """
|
||||
shard = 2
|
||||
|
||||
def setUp(self):
|
||||
super(LmsIndexPageTest, self).setUp()
|
||||
|
||||
# Useful to capture the current datetime for our tests
|
||||
self.now = datetime.datetime.now()
|
||||
|
||||
def test_index_basic_request(self):
|
||||
"""
|
||||
Perform a general validation of the index page, renders normally, no exceptions raised, etc.
|
||||
"""
|
||||
self.assertTrue(self.page.banner_element.visible)
|
||||
expected_links = [u'About', u'Blog', u'News', u'Help Center', u'Contact', u'Careers', u'Donate']
|
||||
self.assertEqual(self.page.footer_links, expected_links)
|
||||
|
||||
def test_intro_video_hidden_by_default(self):
|
||||
"""
|
||||
Confirm that the intro video is not displayed when using the default configuration
|
||||
"""
|
||||
# Ensure the introduction video element is not shown
|
||||
self.assertFalse(self.page.intro_video_element.visible)
|
||||
|
||||
# Still need to figure out how to swap platform settings in the context of a bok choy test
|
||||
# but we can at least prevent accidental exposure with these validations going forward
|
||||
# Note: 'present' is a DOM check, whereas 'visible' is an actual browser/screen check
|
||||
self.assertFalse(self.page.video_modal_element.present)
|
||||
self.assertFalse(self.page.video_modal_element.visible)
|
||||
@@ -5,31 +5,18 @@ End-to-end tests for the LMS Instructor Dashboard.
|
||||
|
||||
|
||||
import ddt
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from six.moves import range
|
||||
|
||||
from common.test.acceptance.fixtures.certificates import CertificateConfigFixture
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.fixtures.course import CourseFixture
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.common.logout import LogoutPage
|
||||
from common.test.acceptance.pages.common.utils import enroll_user_track
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.pages.lms.create_mode import ModeCreationPage
|
||||
from common.test.acceptance.pages.lms.dashboard import DashboardPage
|
||||
from common.test.acceptance.pages.lms.instructor_dashboard import (
|
||||
InstructorDashboardPage,
|
||||
StudentAdminPage,
|
||||
StudentSpecificAdmin
|
||||
)
|
||||
from common.test.acceptance.pages.lms.login_and_register import CombinedLoginAndRegisterPage
|
||||
from common.test.acceptance.pages.lms.problem import ProblemPage
|
||||
from common.test.acceptance.pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
|
||||
from common.test.acceptance.tests.helpers import (
|
||||
EventsTestMixin,
|
||||
UniqueCourseTest,
|
||||
create_multiple_choice_problem,
|
||||
disable_animations,
|
||||
get_modal_alert
|
||||
)
|
||||
from openedx.core.lib.tests import attr
|
||||
|
||||
@@ -101,11 +88,6 @@ class BulkEmailTest(BaseInstructorDashboardTest):
|
||||
instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
self.send_email_page = instructor_dashboard_page.select_bulk_email()
|
||||
|
||||
@ddt.data(["myself"], ["staff"], ["learners"], ["myself", "staff", "learners"])
|
||||
def test_email_queued_for_sending(self, recipient):
|
||||
self.send_email_page.send_message(recipient)
|
||||
self.send_email_page.verify_message_queued_successfully()
|
||||
|
||||
@attr('a11y')
|
||||
def test_bulk_email_a11y(self):
|
||||
"""
|
||||
@@ -138,99 +120,8 @@ class AutoEnrollmentWithCSVTest(BaseInstructorDashboardTest):
|
||||
instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
self.auto_enroll_section = instructor_dashboard_page.select_membership().select_auto_enroll_section()
|
||||
# Initialize the page objects
|
||||
self.register_page = CombinedLoginAndRegisterPage(self.browser, start_page="register")
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
|
||||
def test_browse_and_upload_buttons_are_visible(self):
|
||||
"""
|
||||
Scenario: On the Membership tab of the Instructor Dashboard, Auto-Enroll Browse and Upload buttons are visible.
|
||||
Given that I am on the Membership tab on the Instructor Dashboard
|
||||
Then I see the 'REGISTER/ENROLL STUDENTS' section on the page with the 'Browse' and 'Upload' buttons
|
||||
"""
|
||||
self.assertTrue(self.auto_enroll_section.is_file_attachment_browse_button_visible())
|
||||
self.assertTrue(self.auto_enroll_section.is_upload_button_visible())
|
||||
|
||||
def test_enroll_unregister_student(self):
|
||||
"""
|
||||
Scenario: On the Membership tab of the Instructor Dashboard, Batch Enrollment div is visible.
|
||||
Given that I am on the Membership tab on the Instructor Dashboard
|
||||
Then I enter the email and enroll it.
|
||||
Logout the current page.
|
||||
And Navigate to the registration page and register the student.
|
||||
Then I see the course which enrolled the student.
|
||||
"""
|
||||
username = "test_{uuid}".format(uuid=self.unique_id[0:6])
|
||||
email = "{user}@example.com".format(user=username)
|
||||
self.auto_enroll_section.fill_enrollment_batch_text_box(email)
|
||||
self.assertIn(
|
||||
'Successfully sent enrollment emails to the following users. '
|
||||
'They will be enrolled once they register:',
|
||||
self.auto_enroll_section.get_notification_text()
|
||||
)
|
||||
LogoutPage(self.browser).visit()
|
||||
self.register_page.visit()
|
||||
self.register_page.register(
|
||||
email=email,
|
||||
password="123456",
|
||||
username=username,
|
||||
full_name="Test User",
|
||||
country="US",
|
||||
favorite_movie="Harry Potter",
|
||||
)
|
||||
course_names = self.dashboard_page.wait_for_page().available_courses
|
||||
self.assertEqual(len(course_names), 1)
|
||||
self.assertIn(self.course_info["display_name"], course_names)
|
||||
|
||||
def test_clicking_file_upload_button_without_file_shows_error(self):
|
||||
"""
|
||||
Scenario: Clicking on the upload button without specifying a CSV file results in error.
|
||||
Given that I am on the Membership tab on the Instructor Dashboard
|
||||
When I click the Upload Button without specifying a CSV file
|
||||
Then I should be shown an Error Notification
|
||||
And The Notification message should read 'File is not attached.'
|
||||
"""
|
||||
self.auto_enroll_section.click_upload_file_button()
|
||||
self.assertTrue(self.auto_enroll_section.is_notification_displayed(section_type=self.auto_enroll_section.NOTIFICATION_ERROR))
|
||||
self.assertEqual(self.auto_enroll_section.first_notification_message(section_type=self.auto_enroll_section.NOTIFICATION_ERROR), "File is not attached.")
|
||||
|
||||
def test_uploading_correct_csv_file_results_in_success(self):
|
||||
"""
|
||||
Scenario: Uploading a CSV with correct data results in Success.
|
||||
Given that I am on the Membership tab on the Instructor Dashboard
|
||||
When I select a csv file with correct data and click the Upload Button
|
||||
Then I should be shown a Success Notification.
|
||||
"""
|
||||
self.auto_enroll_section.upload_correct_csv_file()
|
||||
self.assertTrue(self.auto_enroll_section.is_notification_displayed(section_type=self.auto_enroll_section.NOTIFICATION_SUCCESS))
|
||||
|
||||
def test_uploading_csv_file_with_bad_data_results_in_errors_and_warnings(self):
|
||||
"""
|
||||
Scenario: Uploading a CSV with incorrect data results in error and warnings.
|
||||
Given that I am on the Membership tab on the Instructor Dashboard
|
||||
When I select a csv file with incorrect data and click the Upload Button
|
||||
Then I should be shown an Error Notification
|
||||
And a corresponding Error Message.
|
||||
And I should be shown a Warning Notification
|
||||
And a corresponding Warning Message.
|
||||
"""
|
||||
self.auto_enroll_section.upload_csv_file_with_errors_warnings()
|
||||
self.assertTrue(self.auto_enroll_section.is_notification_displayed(section_type=self.auto_enroll_section.NOTIFICATION_ERROR))
|
||||
self.assertEqual(self.auto_enroll_section.first_notification_message(section_type=self.auto_enroll_section.NOTIFICATION_ERROR), "Data in row #2 must have exactly four columns: email, username, full name, and country")
|
||||
self.assertTrue(self.auto_enroll_section.is_notification_displayed(section_type=self.auto_enroll_section.NOTIFICATION_WARNING))
|
||||
self.assertEqual(self.auto_enroll_section.first_notification_message(section_type=self.auto_enroll_section.NOTIFICATION_WARNING), "ename (d@a.com): (An account with email d@a.com exists but the provided username ename is different. Enrolling anyway with d@a.com.)")
|
||||
|
||||
def test_uploading_non_csv_file_results_in_error(self):
|
||||
"""
|
||||
Scenario: Uploading an image file for auto-enrollment results in error.
|
||||
Given that I am on the Membership tab on the Instructor Dashboard
|
||||
When I select an image file (a non-csv file) and click the Upload Button
|
||||
Then I should be shown an Error Notification
|
||||
And The Notification message should read 'Make sure that the file you upload is in CSV..'
|
||||
"""
|
||||
self.auto_enroll_section.upload_non_csv_file()
|
||||
self.assertTrue(self.auto_enroll_section.is_notification_displayed(section_type=self.auto_enroll_section.NOTIFICATION_ERROR))
|
||||
self.assertEqual(self.auto_enroll_section.first_notification_message(section_type=self.auto_enroll_section.NOTIFICATION_ERROR), "Make sure that the file you upload is in CSV format with no extraneous characters or rows.")
|
||||
|
||||
@attr('a11y')
|
||||
def test_auto_enroll_csv_a11y(self):
|
||||
"""
|
||||
@@ -242,356 +133,6 @@ class AutoEnrollmentWithCSVTest(BaseInstructorDashboardTest):
|
||||
self.auto_enroll_section.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
|
||||
@attr(shard=10)
|
||||
class ProctoredExamsTest(BaseInstructorDashboardTest):
|
||||
"""
|
||||
End-to-end tests for Proctoring Sections of the Instructor Dashboard.
|
||||
"""
|
||||
|
||||
USERNAME = "STUDENT_TESTER"
|
||||
EMAIL = "student101@example.com"
|
||||
|
||||
def setUp(self):
|
||||
super(ProctoredExamsTest, self).setUp()
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
|
||||
self.studio_course_outline = StudioCourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
course_fixture = CourseFixture(**self.course_info)
|
||||
course_fixture.add_advanced_settings({
|
||||
"enable_proctored_exams": {"value": "true"}
|
||||
})
|
||||
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection 1').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Problem 1')
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
self.problem_page = ProblemPage(self.browser)
|
||||
|
||||
# Add a verified mode to the course
|
||||
ModeCreationPage(
|
||||
self.browser, self.course_id, mode_slug=u'verified', mode_display_name=u'Verified Certificate',
|
||||
min_price=10, suggested_prices='10,20'
|
||||
).visit()
|
||||
|
||||
# Auto-auth register for the course.
|
||||
self._auto_auth(self.USERNAME, self.EMAIL, False)
|
||||
|
||||
def _auto_auth(self, username, email, staff):
|
||||
"""
|
||||
Logout and login with given credentials.
|
||||
"""
|
||||
AutoAuthPage(self.browser, username=username, email=email,
|
||||
course_id=self.course_id, staff=staff).visit()
|
||||
|
||||
def _login_as_a_verified_user(self):
|
||||
"""
|
||||
login as a verififed user
|
||||
"""
|
||||
|
||||
self._auto_auth(self.USERNAME, self.EMAIL, False)
|
||||
enroll_user_track(self.browser, self.course_id, 'verified')
|
||||
|
||||
def _create_a_proctored_exam_and_attempt(self):
|
||||
"""
|
||||
Creates a proctored exam and makes the student attempt it so that
|
||||
the associated allowance and attempts are visible on the Instructor Dashboard.
|
||||
"""
|
||||
# Visit the course outline page in studio
|
||||
LogoutPage(self.browser).visit()
|
||||
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
|
||||
self.studio_course_outline.visit()
|
||||
|
||||
# open the exam settings to make it a proctored exam.
|
||||
self.studio_course_outline.open_subsection_settings_dialog()
|
||||
|
||||
# select advanced settings tab
|
||||
self.studio_course_outline.select_advanced_tab()
|
||||
|
||||
self.studio_course_outline.make_exam_proctored()
|
||||
|
||||
# login as a verified student and visit the courseware.
|
||||
LogoutPage(self.browser).visit()
|
||||
self._login_as_a_verified_user()
|
||||
self.courseware_page.visit()
|
||||
|
||||
# Start the proctored exam.
|
||||
self.courseware_page.start_proctored_exam()
|
||||
|
||||
def _create_a_timed_exam_and_attempt(self):
|
||||
"""
|
||||
Creates a timed exam and makes the student attempt it so that
|
||||
the associated allowance and attempts are visible on the Instructor Dashboard.
|
||||
"""
|
||||
# Visit the course outline page in studio
|
||||
LogoutPage(self.browser).visit()
|
||||
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
|
||||
self.studio_course_outline.visit()
|
||||
|
||||
# open the exam settings to make it a proctored exam.
|
||||
self.studio_course_outline.open_subsection_settings_dialog()
|
||||
|
||||
# select advanced settings tab
|
||||
self.studio_course_outline.select_advanced_tab()
|
||||
|
||||
self.studio_course_outline.make_exam_timed()
|
||||
|
||||
# login as a verified student and visit the courseware.
|
||||
LogoutPage(self.browser).visit()
|
||||
self._login_as_a_verified_user()
|
||||
self.courseware_page.visit()
|
||||
|
||||
# Start the timed exam.
|
||||
self.courseware_page.start_timed_exam()
|
||||
|
||||
# Stop the timed exam.
|
||||
self.courseware_page.stop_timed_exam()
|
||||
LogoutPage(self.browser).visit()
|
||||
|
||||
def test_can_reset_attempts(self):
|
||||
"""
|
||||
Make sure that Exam attempts are visible and can be reset.
|
||||
"""
|
||||
# Given that an exam has been configured to be a proctored exam.
|
||||
self._create_a_timed_exam_and_attempt()
|
||||
|
||||
# When I log in as an instructor,
|
||||
__, __, __, __ = self.log_in_as_instructor()
|
||||
|
||||
# And visit the Student Proctored Exam Attempts Section of Instructor Dashboard's Special Exams tab
|
||||
instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
exam_attempts_section = instructor_dashboard_page.select_special_exams().select_exam_attempts_section()
|
||||
|
||||
# Then I can see the search text field
|
||||
self.assertTrue(exam_attempts_section.is_search_text_field_visible)
|
||||
|
||||
# And I can see one attempt by a student.
|
||||
self.assertTrue(exam_attempts_section.is_student_attempt_visible)
|
||||
|
||||
# And I can remove the attempt by clicking the "x" at the end of the row.
|
||||
exam_attempts_section.remove_student_attempt()
|
||||
self.assertFalse(exam_attempts_section.is_student_attempt_visible)
|
||||
|
||||
|
||||
@attr(shard=10)
|
||||
class DataDownloadsTest(BaseInstructorDashboardTest):
|
||||
"""
|
||||
Bok Choy tests for the "Data Downloads" tab.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(DataDownloadsTest, self).setUp()
|
||||
self.course_fixture = CourseFixture(**self.course_info).install()
|
||||
self.instructor_username, self.instructor_id, __, __ = self.log_in_as_instructor(
|
||||
course_access_roles=['data_researcher']
|
||||
)
|
||||
instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
self.data_download_section = instructor_dashboard_page.select_data_download()
|
||||
|
||||
def verify_report_requested_event(self, report_type):
|
||||
"""
|
||||
Verifies that the correct event is emitted when a report is requested.
|
||||
"""
|
||||
self.assert_matching_events_were_emitted(
|
||||
event_filter={'name': u'edx.instructor.report.requested', 'report_type': report_type}
|
||||
)
|
||||
|
||||
def verify_report_downloaded_event(self, report_url):
|
||||
"""
|
||||
Verifies that the correct event is emitted when a report is downloaded.
|
||||
"""
|
||||
self.assert_matching_events_were_emitted(
|
||||
event_filter={'name': u'edx.instructor.report.downloaded', 'report_url': report_url}
|
||||
)
|
||||
|
||||
def verify_report_download(self, report_name):
|
||||
"""
|
||||
Verifies that a report can be downloaded and an event fired.
|
||||
"""
|
||||
download_links = self.data_download_section.report_download_links
|
||||
self.assertEqual(len(download_links), 1)
|
||||
download_links[0].click()
|
||||
expected_url = download_links.attrs('href')[0]
|
||||
self.assertIn(report_name, expected_url)
|
||||
self.verify_report_downloaded_event(expected_url)
|
||||
|
||||
def test_student_profiles_report_download(self):
|
||||
"""
|
||||
Scenario: Verify that an instructor can download a student profiles report
|
||||
|
||||
Given that I am an instructor
|
||||
And I visit the instructor dashboard's "Data Downloads" tab
|
||||
And I click on the "Download profile information as a CSV" button
|
||||
Then a report should be generated
|
||||
And a report requested event should be emitted
|
||||
When I click on the report
|
||||
Then a report downloaded event should be emitted
|
||||
"""
|
||||
report_name = u"student_profile_info"
|
||||
self.data_download_section.generate_student_report_button.click()
|
||||
self.data_download_section.wait_for_available_report()
|
||||
self.verify_report_requested_event(report_name)
|
||||
self.verify_report_download(report_name)
|
||||
|
||||
def test_grade_report_download(self):
|
||||
"""
|
||||
Scenario: Verify that an instructor can download a grade report
|
||||
|
||||
Given that I am an instructor
|
||||
And I visit the instructor dashboard's "Data Downloads" tab
|
||||
And I click on the "Generate Grade Report" button
|
||||
Then a report should be generated
|
||||
And a report requested event should be emitted
|
||||
When I click on the report
|
||||
Then a report downloaded event should be emitted
|
||||
"""
|
||||
report_name = u"grade_report"
|
||||
self.data_download_section.generate_grade_report_button.click()
|
||||
self.data_download_section.wait_for_available_report()
|
||||
self.verify_report_requested_event(report_name)
|
||||
self.verify_report_download(report_name)
|
||||
|
||||
def test_problem_grade_report_download(self):
|
||||
"""
|
||||
Scenario: Verify that an instructor can download a problem grade report
|
||||
|
||||
Given that I am an instructor
|
||||
And I visit the instructor dashboard's "Data Downloads" tab
|
||||
And I click on the "Generate Problem Grade Report" button
|
||||
Then a report should be generated
|
||||
And a report requested event should be emitted
|
||||
When I click on the report
|
||||
Then a report downloaded event should be emitted
|
||||
"""
|
||||
report_name = u"problem_grade_report"
|
||||
self.data_download_section.generate_problem_report_button.click()
|
||||
self.data_download_section.wait_for_available_report()
|
||||
self.verify_report_requested_event(report_name)
|
||||
self.verify_report_download(report_name)
|
||||
|
||||
def test_ora2_response_report_download(self):
|
||||
"""
|
||||
Scenario: Verify that an instructor can download an ORA2 grade report
|
||||
|
||||
Given that I am an instructor
|
||||
And I visit the instructor dashboard's "Data Downloads" tab
|
||||
And I click on the "Download ORA2 Responses" button
|
||||
Then a report should be generated
|
||||
"""
|
||||
report_name = u"ORA_data"
|
||||
self.data_download_section.generate_ora2_response_report_button.click()
|
||||
self.data_download_section.wait_for_available_report()
|
||||
self.verify_report_download(report_name)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class DataDownloadsWithMultipleRoleTests(BaseInstructorDashboardTest):
|
||||
"""
|
||||
Bok Choy tests for the "Data Downloads" tab with multiple user roles.
|
||||
"""
|
||||
shard = 23
|
||||
|
||||
def setUp(self):
|
||||
super(DataDownloadsWithMultipleRoleTests, self).setUp()
|
||||
self.course_fixture = CourseFixture(**self.course_info).install()
|
||||
|
||||
@ddt.data(['staff'], ['instructor'])
|
||||
def test_list_student_profile_information_for_large_course(self, role):
|
||||
"""
|
||||
Scenario: List enrolled students' profile information for a large course
|
||||
Given I am "<Role>" for a very large course
|
||||
When I visit the "Data Download" tab
|
||||
Then I do not see a button to 'List enrolled students' profile information'
|
||||
Examples:
|
||||
| Role |
|
||||
| instructor |
|
||||
| staff |
|
||||
|
||||
"""
|
||||
username, __, email, password = self.log_in_as_instructor(
|
||||
global_staff=False,
|
||||
course_access_roles=role
|
||||
)
|
||||
instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
data_download_section = instructor_dashboard_page.select_data_download()
|
||||
|
||||
self.assertTrue(data_download_section.enrolled_student_profile_button_present)
|
||||
LogoutPage(self.browser).visit()
|
||||
for __ in range(5):
|
||||
learner_username = "test_student_{uuid}".format(uuid=self.unique_id[0:8])
|
||||
learner_email = "{user}@example.com".format(user=learner_username)
|
||||
|
||||
# Enroll test users in the course
|
||||
AutoAuthPage(
|
||||
self.browser,
|
||||
username=learner_username,
|
||||
email=learner_email,
|
||||
course_id=self.course_id
|
||||
).visit()
|
||||
|
||||
# Login again with staff or instructor
|
||||
AutoAuthPage(
|
||||
self.browser,
|
||||
username=username,
|
||||
email=email,
|
||||
password=password,
|
||||
course_id=self.course_id,
|
||||
staff=False,
|
||||
course_access_roles=role
|
||||
).visit()
|
||||
|
||||
instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
instructor_dashboard_page.select_data_download()
|
||||
self.assertFalse(data_download_section.enrolled_student_profile_button_present)
|
||||
|
||||
@ddt.data(['staff'], ['instructor'])
|
||||
def test_view_grading_configuration(self, role):
|
||||
"""
|
||||
Scenario: View the grading configuration
|
||||
Given I am "<Role>" for a course
|
||||
When I click "Grading Configuration"
|
||||
Then I see the grading configuration for the course
|
||||
Examples:
|
||||
| Role |
|
||||
| instructor |
|
||||
| staff |
|
||||
"""
|
||||
expected = u"""-----------------------------------------------------------------------------
|
||||
Course grader:
|
||||
<class 'xmodule.graders.WeightedSubsectionsGrader'>
|
||||
|
||||
Graded sections:
|
||||
subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>, type=Homework, category=Homework, weight=0.15
|
||||
subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>, type=Lab, category=Lab, weight=0.15
|
||||
subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>, type=Midterm Exam, category=Midterm Exam, weight=0.3
|
||||
subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>, type=Final Exam, category=Final Exam, weight=0.4
|
||||
-----------------------------------------------------------------------------
|
||||
Listing grading context for course {}
|
||||
graded sections:
|
||||
[]
|
||||
all graded blocks:
|
||||
length=0""".format(self.course_id)
|
||||
self.log_in_as_instructor(
|
||||
global_staff=False,
|
||||
course_access_roles=role
|
||||
)
|
||||
instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
data_download_section = instructor_dashboard_page.select_data_download()
|
||||
|
||||
data_download_section.generate_grading_configuration_button.click()
|
||||
self.assertEqual(data_download_section.grading_config_text, expected)
|
||||
|
||||
|
||||
@attr(shard=10)
|
||||
@ddt.ddt
|
||||
class CertificatesTest(BaseInstructorDashboardTest):
|
||||
@@ -618,295 +159,6 @@ class CertificatesTest(BaseInstructorDashboardTest):
|
||||
self.certificates_section = self.instructor_dashboard_page.select_certificates()
|
||||
disable_animations(self.certificates_section)
|
||||
|
||||
def test_generate_certificates_buttons_is_disable(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Generate Certificates button is disable.
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
The instructor-generation and cert_html_view_enabled feature flags have been enabled
|
||||
But the certificate is not active in settings.
|
||||
Then I see a 'Generate Certificates' button disabled
|
||||
"""
|
||||
self.test_certificate_config['is_active'] = False
|
||||
self.cert_fixture.update_certificate(1)
|
||||
self.browser.refresh()
|
||||
self.assertFalse(self.certificates_section.generate_certificates_button.visible)
|
||||
self.assertTrue(self.certificates_section.generate_certificates_disabled_button.visible)
|
||||
|
||||
def test_generate_certificates_buttons_is_visible(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Generate Certificates button is visible.
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
And the instructor-generation feature flag has been enabled
|
||||
Then I see a 'Generate Certificates' button
|
||||
And when I click on the 'Generate Certificates' button
|
||||
Then I should see a status message and 'Generate Certificates' button should be disabled.
|
||||
"""
|
||||
self.assertTrue(self.certificates_section.generate_certificates_button.visible)
|
||||
self.certificates_section.generate_certificates_button.click()
|
||||
alert = get_modal_alert(self.certificates_section.browser)
|
||||
alert.accept()
|
||||
|
||||
self.certificates_section.wait_for_ajax()
|
||||
EmptyPromise(
|
||||
lambda: self.certificates_section.certificate_generation_status.visible,
|
||||
'Certificate generation status shown'
|
||||
).fulfill()
|
||||
disabled = self.certificates_section.generate_certificates_button.attrs('disabled')
|
||||
self.assertEqual(disabled[0], 'true')
|
||||
|
||||
def test_pending_tasks_section_is_visible(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Pending Instructor Tasks section is visible.
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
Then I see 'Pending Instructor Tasks' section
|
||||
"""
|
||||
self.assertTrue(self.certificates_section.pending_tasks_section.visible)
|
||||
|
||||
def test_certificate_exceptions_section_is_visible(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Certificate Exceptions section is visible.
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
Then I see 'CERTIFICATE EXCEPTIONS' section
|
||||
"""
|
||||
self.assertTrue(self.certificates_section.certificate_exceptions_section.visible)
|
||||
|
||||
def test_instructor_can_add_certificate_exception(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can add new certificate
|
||||
exception to list.
|
||||
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
When I fill in student username and notes fields and click 'Add Exception' button
|
||||
Then new certificate exception should be visible in certificate exceptions list
|
||||
"""
|
||||
notes = 'Test Notes'
|
||||
# Add a student to Certificate exception list
|
||||
self.certificates_section.add_certificate_exception(self.user_name, notes)
|
||||
self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text)
|
||||
self.assertIn(notes, self.certificates_section.last_certificate_exception.text)
|
||||
|
||||
# Verify that added exceptions are also synced with backend
|
||||
# Revisit Page
|
||||
self.certificates_section.refresh()
|
||||
|
||||
# wait for the certificate exception section to render
|
||||
self.certificates_section.wait_for_certificate_exceptions_section()
|
||||
|
||||
# validate certificate exception synced with server is visible in certificate exceptions list
|
||||
self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text)
|
||||
self.assertIn(notes, self.certificates_section.last_certificate_exception.text)
|
||||
|
||||
def test_remove_certificate_exception_on_page_reload(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can remove added certificate
|
||||
exceptions from the list.
|
||||
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
When I fill in student username and notes fields and click 'Add Exception' button
|
||||
Then new certificate exception should be visible in certificate exceptions list
|
||||
|
||||
Revisit the page to make sure exceptions are synced.
|
||||
|
||||
Remove the user from the exception list should remove the user from the list.
|
||||
"""
|
||||
notes = 'Test Notes'
|
||||
# Add a student to Certificate exception list
|
||||
self.certificates_section.add_certificate_exception(self.user_name, notes)
|
||||
self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text)
|
||||
self.assertIn(notes, self.certificates_section.last_certificate_exception.text)
|
||||
|
||||
# Verify that added exceptions are also synced with backend
|
||||
# Revisit Page
|
||||
self.certificates_section.refresh()
|
||||
|
||||
# Remove Certificate Exception
|
||||
self.certificates_section.remove_first_certificate_exception()
|
||||
self.assertNotIn(self.user_name, self.certificates_section.last_certificate_exception.text)
|
||||
self.assertNotIn(notes, self.certificates_section.last_certificate_exception.text)
|
||||
|
||||
def test_instructor_can_remove_certificate_exception(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can remove added certificate
|
||||
exceptions from the list.
|
||||
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
When I fill in student username and notes fields and click 'Add Exception' button
|
||||
Then new certificate exception should be visible in certificate exceptions list
|
||||
"""
|
||||
notes = 'Test Notes'
|
||||
# Add a student to Certificate exception list
|
||||
self.certificates_section.add_certificate_exception(self.user_name, notes)
|
||||
self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text)
|
||||
self.assertIn(notes, self.certificates_section.last_certificate_exception.text)
|
||||
|
||||
# Remove Certificate Exception
|
||||
self.certificates_section.remove_first_certificate_exception()
|
||||
self.assertNotIn(self.user_name, self.certificates_section.last_certificate_exception.text)
|
||||
self.assertNotIn(notes, self.certificates_section.last_certificate_exception.text)
|
||||
|
||||
# Verify that added exceptions are also synced with backend
|
||||
# Revisit Page
|
||||
self.certificates_section.refresh()
|
||||
|
||||
# wait for the certificate exception section to render
|
||||
self.certificates_section.wait_for_certificate_exceptions_section()
|
||||
|
||||
# validate certificate exception synced with server is visible in certificate exceptions list
|
||||
self.assertNotIn(self.user_name, self.certificates_section.last_certificate_exception.text)
|
||||
self.assertNotIn(notes, self.certificates_section.last_certificate_exception.text)
|
||||
|
||||
def test_error_on_duplicate_certificate_exception(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard,
|
||||
Error message appears if student being added already exists in certificate exceptions list
|
||||
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
When I fill in student username that already is in the list and click 'Add Exception' button
|
||||
Then Error Message should say 'User (username/email={user}) already in exception list.'
|
||||
"""
|
||||
# Add a student to Certificate exception list
|
||||
self.certificates_section.add_certificate_exception(self.user_name, '')
|
||||
|
||||
# Add duplicate student to Certificate exception list
|
||||
self.certificates_section.add_certificate_exception(self.user_name, '')
|
||||
|
||||
self.assertIn(
|
||||
u'{user} already in exception list.'.format(user=self.user_name),
|
||||
self.certificates_section.message.text
|
||||
)
|
||||
|
||||
def test_error_on_empty_user_name(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard,
|
||||
Error message appears if no username/email is entered while clicking "Add Exception" button
|
||||
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
When I click on 'Add Exception' button
|
||||
AND student username/email field is empty
|
||||
Then Error Message should say
|
||||
'Student username/email field is required and can not be empty. '
|
||||
'Kindly fill in username/email and then press "Add Exception" button.'
|
||||
"""
|
||||
# Click 'Add Exception' button without filling username/email field
|
||||
self.certificates_section.wait_for_certificate_exceptions_section()
|
||||
self.certificates_section.click_add_exception_button()
|
||||
|
||||
self.assertIn(
|
||||
'Student username/email field is required and can not be empty. '
|
||||
'Kindly fill in username/email and then press "Add to Exception List" button.',
|
||||
self.certificates_section.message.text
|
||||
)
|
||||
|
||||
def test_error_on_non_existing_user(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard,
|
||||
Error message appears if username/email does not exists in the system while clicking "Add Exception" button
|
||||
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
When I click on 'Add Exception' button
|
||||
AND student username/email does not exists
|
||||
Then Error Message should say
|
||||
'Student username/email field is required and can not be empty. '
|
||||
'Kindly fill in username/email and then press "Add Exception" button.
|
||||
"""
|
||||
invalid_user = 'test_user_non_existent'
|
||||
# Click 'Add Exception' button with invalid username/email field
|
||||
self.certificates_section.wait_for_certificate_exceptions_section()
|
||||
|
||||
self.certificates_section.fill_user_name_field(invalid_user)
|
||||
self.certificates_section.click_add_exception_button()
|
||||
self.certificates_section.wait_for_ajax()
|
||||
|
||||
self.assertIn(
|
||||
u"{user} does not exist in the LMS. Please check your spelling and retry.".format(user=invalid_user),
|
||||
self.certificates_section.message.text
|
||||
)
|
||||
|
||||
def test_user_not_enrolled_error(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard,
|
||||
Error message appears if user is not enrolled in the course while trying to add a new exception.
|
||||
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
When I click on 'Add Exception' button
|
||||
AND student is not enrolled in the course
|
||||
Then Error Message should say
|
||||
"The user (username/email={user}) you have entered is not enrolled in this course.
|
||||
Make sure the username or email address is correct, then try again."
|
||||
"""
|
||||
new_user = 'test_user_{uuid}'.format(uuid=self.unique_id[6:12])
|
||||
new_email = 'test_user_{uuid}@example.com'.format(uuid=self.unique_id[6:12])
|
||||
# Create a new user who is not enrolled in the course
|
||||
AutoAuthPage(self.browser, username=new_user, email=new_email).visit()
|
||||
# Login as instructor and visit Certificate Section of Instructor Dashboard
|
||||
self.user_name, self.user_id, __, __ = self.log_in_as_instructor()
|
||||
self.instructor_dashboard_page.visit()
|
||||
self.certificates_section = self.instructor_dashboard_page.select_certificates()
|
||||
|
||||
# Click 'Add Exception' button with invalid username/email field
|
||||
self.certificates_section.wait_for_certificate_exceptions_section()
|
||||
|
||||
self.certificates_section.fill_user_name_field(new_user)
|
||||
self.certificates_section.click_add_exception_button()
|
||||
self.certificates_section.wait_for_ajax()
|
||||
|
||||
self.assertIn(
|
||||
u"{user} is not enrolled in this course. Please check your spelling and retry.".format(user=new_user),
|
||||
self.certificates_section.message.text
|
||||
)
|
||||
|
||||
def test_generate_certificate_exception(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, when user clicks
|
||||
'Generate Exception Certificates' newly added certificate exceptions should be synced on server
|
||||
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
When I click 'Generate Exception Certificates'
|
||||
Then newly added certificate exceptions should be synced on server
|
||||
"""
|
||||
# Add a student to Certificate exception list
|
||||
self.certificates_section.add_certificate_exception(self.user_name, '')
|
||||
|
||||
# Click 'Generate Exception Certificates' button
|
||||
self.certificates_section.click_generate_certificate_exceptions_button()
|
||||
self.certificates_section.wait_for_ajax()
|
||||
|
||||
self.assertIn(
|
||||
self.user_name + ' has been successfully added to the exception list. Click Generate Exception Certificate'
|
||||
' below to send the certificate.',
|
||||
self.certificates_section.message.text
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
('Test \nNotes', 'Test Notes'),
|
||||
('<Test>Notes</Test>', '<Test>Notes</Test>'),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_notes_escaped_in_add_certificate_exception(self, notes, expected_notes):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can add new certificate
|
||||
exception to list.
|
||||
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
When I fill in student username and notes (which contains character which are needed to be escaped)
|
||||
and click 'Add Exception' button, then new certificate exception should be visible in
|
||||
certificate exceptions list.
|
||||
"""
|
||||
# Add a student to Certificate exception list
|
||||
self.certificates_section.add_certificate_exception(self.user_name, notes)
|
||||
self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text)
|
||||
self.assertIn(expected_notes, self.certificates_section.last_certificate_exception.text)
|
||||
|
||||
# Revisit Page & verify that added exceptions are also synced with backend
|
||||
self.certificates_section.refresh()
|
||||
|
||||
# Wait for the certificate exception section to render
|
||||
self.certificates_section.wait_for_certificate_exceptions_section()
|
||||
|
||||
# Validate certificate exception synced with server is visible in certificate exceptions list
|
||||
self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text)
|
||||
self.assertIn(expected_notes, self.certificates_section.last_certificate_exception.text)
|
||||
|
||||
@attr('a11y')
|
||||
def test_certificates_a11y(self):
|
||||
"""
|
||||
@@ -977,150 +229,6 @@ class CertificateInvalidationTest(BaseInstructorDashboardTest):
|
||||
|
||||
disable_animations(self.certificates_section)
|
||||
|
||||
def test_instructor_can_invalidate_certificate(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can add a certificate
|
||||
invalidation to invalidation list.
|
||||
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
When I fill in student username and notes fields and click 'Add Exception' button
|
||||
Then new certificate exception should be visible in certificate exceptions list
|
||||
"""
|
||||
notes = 'Test Notes'
|
||||
# Add a student to certificate invalidation list
|
||||
self.certificates_section.add_certificate_invalidation(self.student_name, notes)
|
||||
self.assertIn(self.student_name, self.certificates_section.last_certificate_invalidation.text)
|
||||
self.assertIn(notes, self.certificates_section.last_certificate_invalidation.text)
|
||||
|
||||
# Validate success message
|
||||
self.assertIn(
|
||||
u"Certificate has been successfully invalidated for {user}.".format(user=self.student_name),
|
||||
self.certificates_section.certificate_invalidation_message.text
|
||||
)
|
||||
|
||||
# Verify that added invalidations are also synced with backend
|
||||
# Revisit Page
|
||||
self.certificates_section.refresh()
|
||||
|
||||
# wait for the certificate invalidations section to render
|
||||
self.certificates_section.wait_for_certificate_invalidations_section()
|
||||
|
||||
# validate certificate invalidation is visible in certificate invalidation list
|
||||
self.assertIn(self.student_name, self.certificates_section.last_certificate_invalidation.text)
|
||||
self.assertIn(notes, self.certificates_section.last_certificate_invalidation.text)
|
||||
|
||||
def test_instructor_can_re_validate_certificate(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can re-validate certificate.
|
||||
|
||||
Given that I am on the certificates tab on the Instructor Dashboard
|
||||
AND there is a certificate invalidation in certificate invalidation table
|
||||
When I click "Remove from Invalidation Table" button
|
||||
Then certificate is re-validated and removed from certificate invalidation table.
|
||||
"""
|
||||
notes = 'Test Notes'
|
||||
# Add a student to certificate invalidation list
|
||||
self.certificates_section.add_certificate_invalidation(self.student_name, notes)
|
||||
self.assertIn(self.student_name, self.certificates_section.last_certificate_invalidation.text)
|
||||
self.assertIn(notes, self.certificates_section.last_certificate_invalidation.text)
|
||||
|
||||
# Verify that added invalidations are also synced with backend
|
||||
# Revisit Page
|
||||
self.certificates_section.refresh()
|
||||
|
||||
# wait for the certificate invalidations section to render
|
||||
self.certificates_section.wait_for_certificate_invalidations_section()
|
||||
|
||||
# click "Remove from Invalidation Table" button next to certificate invalidation
|
||||
self.certificates_section.remove_first_certificate_invalidation()
|
||||
|
||||
# validate certificate invalidation is removed from the list
|
||||
self.assertNotIn(self.student_name, self.certificates_section.last_certificate_invalidation.text)
|
||||
self.assertNotIn(notes, self.certificates_section.last_certificate_invalidation.text)
|
||||
|
||||
self.assertIn(
|
||||
"The certificate for this learner has been re-validated and the system is "
|
||||
"re-running the grade for this learner.",
|
||||
self.certificates_section.certificate_invalidation_message.text
|
||||
)
|
||||
|
||||
def test_error_on_empty_user_name_or_email(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor should see error message if he clicks
|
||||
"Invalidate Certificate" button without entering student username or email.
|
||||
|
||||
Given that I am on the certificates tab on the Instructor Dashboard
|
||||
When I click "Invalidate Certificate" button without entering student username/email.
|
||||
Then I see following error message
|
||||
"Student username/email field is required and can not be empty."
|
||||
"Kindly fill in username/email and then press "Invalidate Certificate" button."
|
||||
"""
|
||||
# Click "Invalidate Certificate" with empty student username/email field
|
||||
self.certificates_section.fill_certificate_invalidation_user_name_field("")
|
||||
self.certificates_section.click_invalidate_certificate_button()
|
||||
self.certificates_section.wait_for_ajax()
|
||||
|
||||
self.assertIn(
|
||||
u'Student username/email field is required and can not be empty. '
|
||||
u'Kindly fill in username/email and then press "Invalidate Certificate" button.',
|
||||
self.certificates_section.certificate_invalidation_message.text
|
||||
)
|
||||
|
||||
def test_error_on_invalid_user(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor should see error message if
|
||||
the student entered for certificate invalidation does not exist.
|
||||
|
||||
Given that I am on the certificates tab on the Instructor Dashboard
|
||||
When I click "Invalidate Certificate"
|
||||
AND the username entered does not exist in the system
|
||||
Then I see following error message
|
||||
"Student username/email field is required and can not be empty."
|
||||
"Kindly fill in username/email and then press "Invalidate Certificate" button."
|
||||
"""
|
||||
invalid_user = "invalid_test_user"
|
||||
# Click "Invalidate Certificate" with invalid student username/email
|
||||
self.certificates_section.fill_certificate_invalidation_user_name_field(invalid_user)
|
||||
self.certificates_section.click_invalidate_certificate_button()
|
||||
self.certificates_section.wait_for_ajax()
|
||||
|
||||
self.assertIn(
|
||||
u"{user} does not exist in the LMS. Please check your spelling and retry.".format(user=invalid_user),
|
||||
self.certificates_section.certificate_invalidation_message.text
|
||||
)
|
||||
|
||||
def test_user_not_enrolled_error(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor should see error message if
|
||||
the student entered for certificate invalidation is not enrolled in the course.
|
||||
|
||||
Given that I am on the certificates tab on the Instructor Dashboard
|
||||
When I click "Invalidate Certificate"
|
||||
AND the username entered is not enrolled in the current course
|
||||
Then I see following error message
|
||||
"{user} is not enrolled in this course. Please check your spelling and retry."
|
||||
"""
|
||||
new_user = 'test_user_{uuid}'.format(uuid=self.unique_id[6:12])
|
||||
new_email = 'test_user_{uuid}@example.com'.format(uuid=self.unique_id[6:12])
|
||||
# Create a new user who is not enrolled in the course
|
||||
AutoAuthPage(self.browser, username=new_user, email=new_email).visit()
|
||||
# Login as instructor and visit Certificate Section of Instructor Dashboard
|
||||
self.user_name, self.user_id, __, __ = self.log_in_as_instructor()
|
||||
self.instructor_dashboard_page.visit()
|
||||
self.certificates_section = self.instructor_dashboard_page.select_certificates()
|
||||
|
||||
# Click 'Invalidate Certificate' button with not enrolled student
|
||||
self.certificates_section.wait_for_certificate_invalidations_section()
|
||||
|
||||
self.certificates_section.fill_certificate_invalidation_user_name_field(new_user)
|
||||
self.certificates_section.click_invalidate_certificate_button()
|
||||
self.certificates_section.wait_for_ajax()
|
||||
|
||||
self.assertIn(
|
||||
u"{user} is not enrolled in this course. Please check your spelling and retry.".format(user=new_user),
|
||||
self.certificates_section.certificate_invalidation_message.text
|
||||
)
|
||||
|
||||
@attr('a11y')
|
||||
def test_invalidate_certificates_a11y(self):
|
||||
"""
|
||||
@@ -1135,154 +243,3 @@ class CertificateInvalidationTest(BaseInstructorDashboardTest):
|
||||
'.certificates-wrapper'
|
||||
])
|
||||
self.certificates_section.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
|
||||
@attr(shard=20)
|
||||
class EcommerceTest(BaseInstructorDashboardTest):
|
||||
"""
|
||||
Bok Choy tests for the "E-Commerce" tab.
|
||||
"""
|
||||
def setup_course(self, course_number):
|
||||
"""
|
||||
Sets up the course
|
||||
"""
|
||||
self.course_info['number'] = course_number
|
||||
course_fixture = CourseFixture(
|
||||
self.course_info["org"],
|
||||
self.course_info["number"],
|
||||
self.course_info["run"],
|
||||
self.course_info["display_name"]
|
||||
)
|
||||
course_fixture.install()
|
||||
|
||||
def visit_ecommerce_section(self):
|
||||
"""
|
||||
Log in to visit Instructor dashboard and click E-commerce tab
|
||||
"""
|
||||
self.log_in_as_instructor(course_access_roles=['finance_admin'])
|
||||
instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
return instructor_dashboard_page.select_ecommerce_tab()
|
||||
|
||||
def add_course_mode(self, sku_value=None):
|
||||
"""
|
||||
Add an honor mode to the course
|
||||
"""
|
||||
ModeCreationPage(browser=self.browser, course_id=self.course_id, mode_slug=u'honor', min_price=10,
|
||||
sku=sku_value).visit()
|
||||
|
||||
def test_enrollment_codes_section_visible_for_non_ecommerce_course(self):
|
||||
"""
|
||||
Test Enrollment Codes UI, under E-commerce Tab, should be visible in the Instructor Dashboard with non
|
||||
e-commerce course
|
||||
"""
|
||||
# Setup course
|
||||
non_ecommerce_course_number = "34039497242734583224814321005482849780"
|
||||
self.setup_course(non_ecommerce_course_number)
|
||||
|
||||
# Add an honor mode to the course
|
||||
self.add_course_mode()
|
||||
|
||||
# Log in and visit E-commerce section under Instructor dashboard
|
||||
self.assertIn(u'Enrollment Codes', self.visit_ecommerce_section().get_sections_header_values())
|
||||
|
||||
def test_coupon_codes_section_visible_for_non_ecommerce_course(self):
|
||||
"""
|
||||
Test Coupon Codes UI, under E-commerce Tab, should be visible in the Instructor Dashboard with non
|
||||
e-commerce course
|
||||
"""
|
||||
# Setup course
|
||||
non_ecommerce_course_number = "34039497242734583224814321005482849781"
|
||||
self.setup_course(non_ecommerce_course_number)
|
||||
|
||||
# Add an honor mode to the course
|
||||
self.add_course_mode()
|
||||
|
||||
# Log in and visit E-commerce section under Instructor dashboard
|
||||
self.assertIn(u'Coupon Code List', self.visit_ecommerce_section().get_sections_header_values())
|
||||
|
||||
def test_enrollment_codes_section_not_visible_for_ecommerce_course(self):
|
||||
"""
|
||||
Test Enrollment Codes UI, under E-commerce Tab, should not be visible in the Instructor Dashboard with
|
||||
e-commerce course
|
||||
"""
|
||||
# Setup course
|
||||
ecommerce_course_number = "34039497242734583224814321005482849782"
|
||||
self.setup_course(ecommerce_course_number)
|
||||
|
||||
# Add an honor mode to the course with sku value
|
||||
self.add_course_mode('test_sku')
|
||||
|
||||
# Log in and visit E-commerce section under Instructor dashboard
|
||||
self.assertNotIn(u'Enrollment Codes', self.visit_ecommerce_section().get_sections_header_values())
|
||||
|
||||
def test_coupon_codes_section_not_visible_for_ecommerce_course(self):
|
||||
"""
|
||||
Test Coupon Codes UI, under E-commerce Tab, should not be visible in the Instructor Dashboard with
|
||||
e-commerce course
|
||||
"""
|
||||
# Setup course
|
||||
ecommerce_course_number = "34039497242734583224814321005482849783"
|
||||
self.setup_course(ecommerce_course_number)
|
||||
|
||||
# Add an honor mode to the course with sku value
|
||||
self.add_course_mode('test_sku')
|
||||
|
||||
# Log in and visit E-commerce section under Instructor dashboard
|
||||
self.assertNotIn(u'Coupon Code List', self.visit_ecommerce_section().get_sections_header_values())
|
||||
|
||||
|
||||
class StudentAdminTest(BaseInstructorDashboardTest):
|
||||
SECTION_NAME = 'Test Section 1'
|
||||
SUBSECTION_NAME = 'Test Subsection 1'
|
||||
UNIT_NAME = 'Test Unit 1'
|
||||
PROBLEM_NAME = 'Test Problem 1'
|
||||
|
||||
shard = 23
|
||||
|
||||
def setUp(self):
|
||||
super(StudentAdminTest, self).setUp()
|
||||
self.course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
self.problem = create_multiple_choice_problem(self.PROBLEM_NAME)
|
||||
self.vertical = XBlockFixtureDesc('vertical', "Lab Unit")
|
||||
self.course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', self.SECTION_NAME).add_children(
|
||||
XBlockFixtureDesc('sequential', self.SUBSECTION_NAME).add_children(
|
||||
self.vertical.add_children(self.problem)
|
||||
)
|
||||
),
|
||||
).install()
|
||||
|
||||
self.username, __, __, __ = self.log_in_as_instructor()
|
||||
self.instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
|
||||
def test_rescore_rescorable(self):
|
||||
student_admin_section = self.instructor_dashboard_page.select_student_admin(StudentSpecificAdmin)
|
||||
student_admin_section.set_student_email_or_username(self.username)
|
||||
student_admin_section.set_problem_location(self.problem.locator)
|
||||
getattr(student_admin_section, 'rescore_button').click()
|
||||
alert = get_modal_alert(student_admin_section.browser)
|
||||
alert.dismiss()
|
||||
self.assertFalse(self.instructor_dashboard_page.is_rescore_unsupported_message_visible())
|
||||
|
||||
def test_task_list_visibility(self):
|
||||
"""
|
||||
Test that instructor task list is visible on student admin section
|
||||
to users who have access to instructor tab/dashboard
|
||||
"""
|
||||
# first check for global staff users
|
||||
student_admin_section = self.instructor_dashboard_page.select_student_admin(StudentAdminPage)
|
||||
self.assertTrue(student_admin_section.running_tasks_section.visible)
|
||||
|
||||
# logout global-staff user and check for users with staff access to course
|
||||
LogoutPage(self.browser).visit()
|
||||
# having staff access to course is compulsory to access instructor dashboard
|
||||
self.log_in_as_instructor(False, ['staff'])
|
||||
self.instructor_dashboard_page = self.visit_instructor_dashboard()
|
||||
student_admin_section = self.instructor_dashboard_page.select_student_admin(StudentAdminPage)
|
||||
self.assertTrue(student_admin_section.running_tasks_section.visible)
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Bok choy acceptance tests for LTI xblock
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
|
||||
from common.test.acceptance.pages.lms.instructor_dashboard import (
|
||||
GradeBookPage,
|
||||
InstructorDashboardPage,
|
||||
StudentAdminPage
|
||||
)
|
||||
from common.test.acceptance.pages.lms.progress import ProgressPage
|
||||
from common.test.acceptance.pages.lms.tab_nav import TabNavPage
|
||||
|
||||
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from ...pages.lms.courseware import CoursewarePage, LTIContentIframe
|
||||
from ..helpers import UniqueCourseTest, auto_auth, select_option_by_text
|
||||
|
||||
|
||||
class TestLTIConsumer(UniqueCourseTest):
|
||||
"""
|
||||
Base class for tests of LTI xblock in the LMS.
|
||||
"""
|
||||
|
||||
USERNAME = "STUDENT_TESTER"
|
||||
EMAIL = "student101@example.com"
|
||||
host = os.environ.get('BOK_CHOY_HOSTNAME', '127.0.0.1')
|
||||
|
||||
def setUp(self):
|
||||
super(TestLTIConsumer, self).setUp()
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
self.lti_iframe = LTIContentIframe(self.browser, self.course_id)
|
||||
self.tab_nav = TabNavPage(self.browser)
|
||||
self.progress_page = ProgressPage(self.browser, self.course_id)
|
||||
self.instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id)
|
||||
self.grade_book_page = GradeBookPage(self.browser)
|
||||
# Install a course
|
||||
display_name = "Test Course" + self.unique_id
|
||||
self.course_fix = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], display_name=display_name
|
||||
)
|
||||
|
||||
def test_lti_no_launch_url_is_not_rendered(self):
|
||||
"""
|
||||
Scenario: LTI component in LMS with no launch_url is not rendered
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with no_launch_url fields:
|
||||
Then I view the LTI and error is shown
|
||||
"""
|
||||
metadata_advance_settings = "correct_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'launch_url': '',
|
||||
'open_in_a_new_page': False
|
||||
}
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.assertTrue(self.courseware_page.is_error_message_present())
|
||||
self.assertFalse(self.courseware_page.is_iframe_present())
|
||||
self.assertFalse(self.courseware_page.is_launch_url_present())
|
||||
|
||||
def test_incorrect_lti_id_is_rendered_incorrectly(self):
|
||||
"""
|
||||
Scenario: LTI component in LMS with incorrect lti_id is rendered incorrectly
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with incorrect_lti_id fields:
|
||||
Then I view the LTI but incorrect_signature warning is rendered
|
||||
"""
|
||||
metadata_advance_settings = "test_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'incorrect_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'open_in_a_new_page': False
|
||||
}
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.assertTrue(self.courseware_page.is_iframe_present())
|
||||
self.assertFalse(self.courseware_page.is_launch_url_present())
|
||||
self.assertFalse(self.courseware_page.is_error_message_present())
|
||||
self.courseware_page.go_to_lti_container()
|
||||
self.assertEqual("Wrong LTI signature", self.lti_iframe.lti_content)
|
||||
|
||||
def test_incorrect_lti_credentials_is_rendered_incorrectly(self):
|
||||
"""
|
||||
Scenario: LTI component in LMS with icorrect LTI credentials is rendered incorrectly
|
||||
Given the course has incorrect LTI credentials with registered Instructor
|
||||
the course has an LTI component with correct fields:
|
||||
I view the LTI but incorrect_signature warning is rendered
|
||||
"""
|
||||
metadata_advance_settings = "test_lti_id:test_client_key:incorrect_lti_secret_key"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'open_in_a_new_page': False
|
||||
}
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.assertTrue(self.courseware_page.is_iframe_present())
|
||||
self.assertFalse(self.courseware_page.is_launch_url_present())
|
||||
self.assertFalse(self.courseware_page.is_error_message_present())
|
||||
self.courseware_page.go_to_lti_container()
|
||||
self.assertEqual("Wrong LTI signature", self.lti_iframe.lti_content)
|
||||
|
||||
def test_lti_is_rendered_in_iframe_correctly(self):
|
||||
"""
|
||||
Scenario: LTI component in LMS is correctly rendered in iframe
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with correct fields:
|
||||
I view the LTI and it is rendered in iframe correctly
|
||||
"""
|
||||
|
||||
metadata_advance_settings = "correct_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'open_in_a_new_page': False
|
||||
}
|
||||
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.assertTrue(self.courseware_page.is_iframe_present())
|
||||
self.assertFalse(self.courseware_page.is_launch_url_present())
|
||||
self.assertFalse(self.courseware_page.is_error_message_present())
|
||||
self.courseware_page.go_to_lti_container()
|
||||
self.assertEqual("This is LTI tool. Success.", self.lti_iframe.lti_content)
|
||||
|
||||
def test_lti_graded_component_for_staff(self):
|
||||
"""
|
||||
Scenario: Graded LTI component in LMS is correctly works for staff
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with correct fields:
|
||||
verify scores on progress and grade book pages.
|
||||
"""
|
||||
metadata_advance_settings = "correct_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'open_in_a_new_page': False,
|
||||
'weight': 10,
|
||||
'graded': True,
|
||||
'has_score': True
|
||||
}
|
||||
expected_scores = [(5, 10)]
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.courseware_page.go_to_lti_container()
|
||||
self.lti_iframe.submit_lti_answer('#submit-button')
|
||||
self.assertIn("LTI consumer (edX) responded with XML content", self.lti_iframe.lti_content)
|
||||
self.lti_iframe.switch_to_default()
|
||||
self.tab_nav.go_to_tab('Progress')
|
||||
actual_scores = self.progress_page.scores("Test Chapter", "Test Section")
|
||||
self.assertEqual(actual_scores, expected_scores)
|
||||
self.assertEqual(['Overall Score', 'Overall Score\n1%'], self.progress_page.graph_overall_score())
|
||||
self.tab_nav.go_to_tab('Instructor')
|
||||
student_admin_section = self.instructor_dashboard_page.select_student_admin(StudentAdminPage)
|
||||
student_admin_section.click_grade_book_link()
|
||||
self.assertEqual("50", self.grade_book_page.get_value_in_the_grade_book('Homework 1 - Test Section', 1))
|
||||
self.assertEqual("1", self.grade_book_page.get_value_in_the_grade_book('Total', 1))
|
||||
|
||||
def test_lti_switch_role_works_correctly(self):
|
||||
"""
|
||||
Scenario: Graded LTI component in LMS role's masquerading correctly works
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with correct fields:
|
||||
switch role from instructor to learner and verify that it works correctly
|
||||
"""
|
||||
metadata_advance_settings = "correct_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'open_in_a_new_page': False,
|
||||
'has_score': True
|
||||
}
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.assertTrue(self.courseware_page.is_iframe_present())
|
||||
self.assertFalse(self.courseware_page.is_launch_url_present())
|
||||
self.assertFalse(self.courseware_page.is_error_message_present())
|
||||
self.courseware_page.go_to_lti_container()
|
||||
self.assertEqual("This is LTI tool. Success.", self.lti_iframe.lti_content)
|
||||
self.assertEqual("Role: Instructor", self.lti_iframe.get_user_role)
|
||||
self.lti_iframe.switch_to_default()
|
||||
select_option_by_text(self.courseware_page.get_role_selector, 'Learner')
|
||||
self.courseware_page.wait_for_ajax()
|
||||
self.assertTrue(self.courseware_page.is_iframe_present())
|
||||
self.assertFalse(self.courseware_page.is_launch_url_present())
|
||||
self.assertFalse(self.courseware_page.is_error_message_present())
|
||||
self.courseware_page.go_to_lti_container()
|
||||
self.assertEqual("This is LTI tool. Success.", self.lti_iframe.lti_content)
|
||||
self.assertEqual("Role: Student", self.lti_iframe.get_user_role)
|
||||
|
||||
def test_lti_graded_component_for_learner(self):
|
||||
"""
|
||||
Scenario: Graded LTI component in LMS is correctly works for learners
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with correct fields:
|
||||
verify scores on progress
|
||||
"""
|
||||
metadata_advance_settings = "correct_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'open_in_a_new_page': False,
|
||||
'weight': 10,
|
||||
'graded': True,
|
||||
'has_score': True
|
||||
}
|
||||
expected_scores = [(5, 10)]
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, False, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.courseware_page.go_to_lti_container()
|
||||
self.lti_iframe.submit_lti_answer('#submit-button')
|
||||
self.assertIn("LTI consumer (edX) responded with XML content", self.lti_iframe.lti_content)
|
||||
self.lti_iframe.switch_to_default()
|
||||
self.tab_nav.go_to_tab('Progress')
|
||||
actual_scores = self.progress_page.scores("Test Chapter", "Test Section")
|
||||
self.assertEqual(actual_scores, expected_scores)
|
||||
self.assertEqual(['Overall Score', 'Overall Score\n1%'], self.progress_page.graph_overall_score())
|
||||
|
||||
def test_lti_v2_callback_graded_component(self):
|
||||
"""
|
||||
Scenario: Graded LTI component in LMS is correctly works with LTI2v0 PUT callback
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with correct fields:
|
||||
verify scores on progress and grade book pages.
|
||||
verify feedback in LTI component.
|
||||
"""
|
||||
metadata_advance_settings = "correct_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'open_in_a_new_page': False,
|
||||
'weight': 10,
|
||||
'graded': True,
|
||||
'has_score': True
|
||||
}
|
||||
expected_scores = [(8, 10)]
|
||||
problem_score = '(8.0 / 10.0 points)'
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.courseware_page.go_to_lti_container()
|
||||
self.lti_iframe.submit_lti_answer("#submit-lti2-button")
|
||||
self.assertIn("LTI consumer (edX) responded with HTTP 200", self.lti_iframe.lti_content)
|
||||
self.lti_iframe.switch_to_default()
|
||||
self.tab_nav.go_to_tab('Progress')
|
||||
actual_scores = self.progress_page.scores("Test Chapter", "Test Section")
|
||||
self.assertEqual(actual_scores, expected_scores)
|
||||
self.assertEqual(['Overall Score', 'Overall Score\n1%'], self.progress_page.graph_overall_score())
|
||||
self.tab_nav.go_to_tab('Instructor')
|
||||
student_admin_section = self.instructor_dashboard_page.select_student_admin(StudentAdminPage)
|
||||
student_admin_section.click_grade_book_link()
|
||||
self.assertEqual("80", self.grade_book_page.get_value_in_the_grade_book('Homework 1 - Test Section', 1))
|
||||
self.assertEqual("1", self.grade_book_page.get_value_in_the_grade_book('Total', 1))
|
||||
self.tab_nav.go_to_tab('Course')
|
||||
self.assertEqual(problem_score, self.courseware_page.get_elem_text('.problem-progress'))
|
||||
self.assertEqual("This is awesome.", self.courseware_page.get_elem_text('.problem-feedback'))
|
||||
|
||||
def test_lti_delete_callback_graded_component(self):
|
||||
"""
|
||||
Scenario: Graded LTI component in LMS is correctly works with LTI2v0 PUT delete callback
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with correct fields:
|
||||
Verify LTI provider deletes my grade on progress and grade book page
|
||||
verify LTI provider deletes feedback from LTI Component
|
||||
"""
|
||||
metadata_advance_settings = "correct_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'open_in_a_new_page': False,
|
||||
'weight': 10,
|
||||
'graded': True,
|
||||
'has_score': True
|
||||
}
|
||||
expected_scores = [(0, 10)]
|
||||
problem_score = '(8.0 / 10.0 points)'
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.courseware_page.go_to_lti_container()
|
||||
self.lti_iframe.submit_lti_answer("#submit-lti2-button")
|
||||
self.assertIn("LTI consumer (edX) responded with HTTP 200", self.lti_iframe.lti_content)
|
||||
self.lti_iframe.switch_to_default()
|
||||
self.courseware_page.visit()
|
||||
self.assertEqual(problem_score, self.courseware_page.get_elem_text('.problem-progress'))
|
||||
self.assertEqual("This is awesome.", self.courseware_page.get_elem_text('.problem-feedback'))
|
||||
self.courseware_page.go_to_lti_container()
|
||||
self.lti_iframe.submit_lti_answer("#submit-lti-delete-button")
|
||||
self.courseware_page.visit()
|
||||
self.assertEqual("(10.0 points possible)", self.courseware_page.get_elem_text('.problem-progress'))
|
||||
self.assertFalse(self.courseware_page.is_lti_component_present('.problem-feedback'))
|
||||
self.tab_nav.go_to_tab('Progress')
|
||||
actual_scores = self.progress_page.scores("Test Chapter", "Test Section")
|
||||
self.assertEqual(actual_scores, expected_scores)
|
||||
self.assertEqual(['Overall Score', 'Overall Score\n0%'], self.progress_page.graph_overall_score())
|
||||
self.tab_nav.go_to_tab('Instructor')
|
||||
student_admin_section = self.instructor_dashboard_page.select_student_admin(StudentAdminPage)
|
||||
student_admin_section.click_grade_book_link()
|
||||
self.assertEqual("0", self.grade_book_page.get_value_in_the_grade_book('Homework 1 - Test Section', 1))
|
||||
self.assertEqual("0", self.grade_book_page.get_value_in_the_grade_book('Total', 1))
|
||||
|
||||
def test_lti_hide_launch_shows_no_button(self):
|
||||
"""
|
||||
Scenario: LTI component that set to hide_launch and open_in_a_new_page shows no button
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with correct fields:
|
||||
verify LTI component don't show launch button with text "LTI (External resource)"
|
||||
"""
|
||||
metadata_advance_settings = "correct_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'open_in_a_new_page': False,
|
||||
'hide_launch': True
|
||||
}
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.assertFalse(self.courseware_page.is_lti_component_present('.link_lti_new_window'))
|
||||
self.assertEqual("LTI (External resource)", self.courseware_page.get_elem_text('.problem-header'))
|
||||
|
||||
def test_lti_hide_launch_shows_no_iframe(self):
|
||||
"""
|
||||
Scenario: LTI component that set to hide_launch and not open_in_a_new_page shows no iframe
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with correct fields:
|
||||
verify LTI component don't show LTI iframe with text "LTI (External resource)"
|
||||
"""
|
||||
metadata_advance_settings = "correct_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'open_in_a_new_page': True,
|
||||
'hide_launch': True
|
||||
}
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.assertFalse(self.courseware_page.is_lti_component_present('.ltiLaunchFrame'))
|
||||
self.assertEqual("LTI (External resource)", self.courseware_page.get_elem_text('.problem-header'))
|
||||
|
||||
def test_lti_button_text_correctly_displayed(self):
|
||||
"""
|
||||
Scenario: LTI component button text is correctly displayed
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with correct fields:
|
||||
verify LTI component button with text "Launch Application"
|
||||
"""
|
||||
metadata_advance_settings = "correct_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'button_text': 'Launch Application'
|
||||
}
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.assertEqual("Launch Application", self.courseware_page.get_elem_text('.link_lti_new_window'))
|
||||
|
||||
def test_lti_component_description_correctly_displayed(self):
|
||||
"""
|
||||
Scenario: LTI component description is correctly displayed
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
the course has an LTI component with correct fields:
|
||||
LTI component description with text "Application description"
|
||||
"""
|
||||
metadata_advance_settings = "correct_lti_id:test_client_key:test_client_secret"
|
||||
metadata_lti_xblock = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/{}'.format(self.host, '8765', 'correct_lti_endpoint'),
|
||||
'description': 'Application description'
|
||||
}
|
||||
self.set_advance_settings(metadata_advance_settings)
|
||||
self.create_lti_xblock(metadata_lti_xblock)
|
||||
auto_auth(self.browser, self.USERNAME, self.EMAIL, True, self.course_id)
|
||||
self.courseware_page.visit()
|
||||
self.assertEqual("Application description", self.courseware_page.get_elem_text('.lti-description'))
|
||||
|
||||
def set_advance_settings(self, metadata_advance_settings):
|
||||
|
||||
# Set value against advanced modules in advanced settings
|
||||
self.course_fix.add_advanced_settings({
|
||||
"advanced_modules": {"value": ["lti_consumer"]},
|
||||
'lti_passports': {"value": [metadata_advance_settings]}
|
||||
})
|
||||
|
||||
def create_lti_xblock(self, metadata_lti_xblock):
|
||||
self.course_fix.add_children(
|
||||
XBlockFixtureDesc(category='chapter', display_name='Test Chapter').add_children(
|
||||
XBlockFixtureDesc(
|
||||
category='sequential', display_name='Test Section', grader_type='Homework', graded=True
|
||||
).add_children(
|
||||
XBlockFixtureDesc(category='lti', display_name='LTI', metadata=metadata_lti_xblock).add_children(
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
@@ -4,18 +4,13 @@ Bok choy acceptance tests for problems in the LMS
|
||||
"""
|
||||
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from textwrap import dedent
|
||||
|
||||
import ddt
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.pages.lms.login_and_register import CombinedLoginAndRegisterPage
|
||||
from common.test.acceptance.pages.lms.problem import ProblemPage, DragAndDropPage
|
||||
from common.test.acceptance.tests.helpers import EventsTestMixin, UniqueCourseTest
|
||||
from common.test.acceptance.pages.lms.problem import ProblemPage
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest
|
||||
from openedx.core.lib.tests import attr
|
||||
|
||||
|
||||
@@ -68,674 +63,6 @@ class ProblemsTest(UniqueCourseTest):
|
||||
return XBlockFixtureDesc('sequential', 'Test Subsection')
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemClarificationTest(ProblemsTest):
|
||||
"""
|
||||
Tests the <clarification> element that can be used in problem XML.
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Create a problem with a <clarification>
|
||||
"""
|
||||
xml = dedent(u"""
|
||||
<problem markdown="null">
|
||||
<text>
|
||||
<p>
|
||||
Given the data in Table 7 <clarification>Table 7: "Example PV Installation Costs",
|
||||
Page 171 of Roberts textbook</clarification>, compute the ROI
|
||||
<clarification>Return on Investment <strong>(per year)</strong></clarification> over 20 years.
|
||||
</p>
|
||||
<numericalresponse answer="6.5">
|
||||
<label>Enter the annual ROI</label>
|
||||
<textline trailing_text="%" />
|
||||
</numericalresponse>
|
||||
</text>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TOOLTIP TEST PROBLEM', data=xml)
|
||||
|
||||
def test_clarification(self):
|
||||
"""
|
||||
Test that we can see the <clarification> tooltips.
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
self.assertEqual(problem_page.problem_name, 'TOOLTIP TEST PROBLEM')
|
||||
problem_page.click_clarification(0)
|
||||
self.assertIn('"Example PV Installation Costs"', problem_page.visible_tooltip_text)
|
||||
problem_page.click_clarification(1)
|
||||
tooltip_text = problem_page.visible_tooltip_text
|
||||
self.assertIn('Return on Investment', tooltip_text)
|
||||
self.assertIn('per year', tooltip_text)
|
||||
self.assertNotIn('strong', tooltip_text)
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemHintTest(ProblemsTest, EventsTestMixin):
|
||||
"""
|
||||
Base test class for problem hint tests.
|
||||
"""
|
||||
def verify_check_hint(self, answer, answer_text, expected_events):
|
||||
"""
|
||||
Verify clicking Check shows the extended hint in the problem message.
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
self.assertEqual(problem_page.problem_text[0], u'question text')
|
||||
problem_page.fill_answer(answer)
|
||||
problem_page.click_submit()
|
||||
self.assertEqual(problem_page.message_text, answer_text)
|
||||
# Check for corresponding tracking event
|
||||
actual_events = self.wait_for_events(
|
||||
event_filter={'event_type': 'edx.problem.hint.feedback_displayed'},
|
||||
number_of_matches=1
|
||||
)
|
||||
self.assert_events_match(expected_events, actual_events)
|
||||
|
||||
def verify_demand_hints(self, first_hint, second_hint, expected_events):
|
||||
"""
|
||||
Test clicking through the demand hints and verify the events sent.
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
|
||||
# The hint notification should not be visible on load
|
||||
self.assertFalse(problem_page.is_hint_notification_visible())
|
||||
|
||||
# The two Hint button should be enabled. One visible, one present, but not visible in the DOM
|
||||
self.assertEqual([None, None], problem_page.get_hint_button_disabled_attr())
|
||||
|
||||
# The hint button rotates through multiple hints
|
||||
problem_page.click_hint(hint_index=0)
|
||||
self.assertTrue(problem_page.is_hint_notification_visible())
|
||||
self.assertEqual(problem_page.hint_text, first_hint)
|
||||
# Now there are two "hint" buttons, as there is also one in the hint notification.
|
||||
self.assertEqual([None, None], problem_page.get_hint_button_disabled_attr())
|
||||
|
||||
problem_page.click_hint(hint_index=1)
|
||||
self.assertEqual(problem_page.hint_text, second_hint)
|
||||
# Now both "hint" buttons should be disabled, as there are no more hints.
|
||||
self.assertEqual(['true', 'true'], problem_page.get_hint_button_disabled_attr())
|
||||
|
||||
# Now click on "Review" and make sure the focus goes to the correct place.
|
||||
problem_page.click_review_in_notification(notification_type='hint')
|
||||
problem_page.wait_for_focus_on_problem_meta()
|
||||
|
||||
# Check corresponding tracking events
|
||||
actual_events = self.wait_for_events(
|
||||
event_filter={'event_type': 'edx.problem.hint.demandhint_displayed'},
|
||||
number_of_matches=2
|
||||
)
|
||||
self.assert_events_match(expected_events, actual_events)
|
||||
|
||||
def get_problem(self):
|
||||
""" Subclasses should override this to complete the fixture """
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemNotificationTests(ProblemsTest):
|
||||
"""
|
||||
Tests that the notifications are visible when expected.
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Problem structure.
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<label>Which of the following countries has the largest population?</label>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">Brazil <choicehint>timely feedback -- explain why an almost correct answer is wrong</choicehint></choice>
|
||||
<choice correct="false">Germany</choice>
|
||||
<choice correct="true">Indonesia</choice>
|
||||
<choice correct="false">Russia</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TEST PROBLEM', data=xml,
|
||||
metadata={'max_attempts': 10},
|
||||
grader_type='Final Exam')
|
||||
|
||||
def test_notification_updates(self):
|
||||
"""
|
||||
Verifies that the notification is removed and not visible when it should be
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
problem_page.click_choice("choice_2")
|
||||
self.assertFalse(problem_page.is_success_notification_visible())
|
||||
problem_page.click_submit()
|
||||
problem_page.wait_success_notification()
|
||||
self.assertEqual('Question 1: correct', problem_page.status_sr_text)
|
||||
|
||||
# Clicking Save should clear the submit notification
|
||||
problem_page.click_save()
|
||||
self.assertFalse(problem_page.is_success_notification_visible())
|
||||
problem_page.wait_for_save_notification()
|
||||
|
||||
# Changing the answer should clear the save notification
|
||||
problem_page.click_choice("choice_1")
|
||||
self.assertFalse(problem_page.is_save_notification_visible())
|
||||
problem_page.click_save()
|
||||
problem_page.wait_for_save_notification()
|
||||
|
||||
# Submitting the problem again should clear the save notification
|
||||
problem_page.click_submit()
|
||||
problem_page.wait_incorrect_notification()
|
||||
self.assertEqual('Question 1: incorrect', problem_page.status_sr_text)
|
||||
self.assertFalse(problem_page.is_save_notification_visible())
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemFeedbackNotificationTests(ProblemsTest):
|
||||
"""
|
||||
Tests that the feedback notifications are visible when expected.
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Problem structure.
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<label>Which of the following countries has the largest population?</label>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">Brazil <choicehint>timely feedback -- explain why an almost correct answer is wrong</choicehint></choice>
|
||||
<choice correct="false">Germany</choice>
|
||||
<choice correct="true">Indonesia</choice>
|
||||
<choice correct="false">Russia</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TEST PROBLEM', data=xml,
|
||||
metadata={'max_attempts': 10},
|
||||
grader_type='Final Exam')
|
||||
|
||||
def test_feedback_notification_hides_after_save(self):
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
problem_page.click_choice("choice_0")
|
||||
problem_page.click_submit()
|
||||
problem_page.wait_for_feedback_message_visibility()
|
||||
problem_page.click_choice("choice_1")
|
||||
problem_page.click_save()
|
||||
self.assertFalse(problem_page.is_feedback_message_notification_visible())
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemSaveStatusUpdateTests(ProblemsTest):
|
||||
"""
|
||||
Tests the problem status updates correctly with an answer change and save.
|
||||
"""
|
||||
def get_problem(self):
|
||||
"""
|
||||
Problem structure.
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<label>Which of the following countries has the largest population?</label>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">Brazil <choicehint>timely feedback -- explain why an almost correct answer is wrong</choicehint></choice>
|
||||
<choice correct="false">Germany</choice>
|
||||
<choice correct="true">Indonesia</choice>
|
||||
<choice correct="false">Russia</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TEST PROBLEM', data=xml,
|
||||
metadata={'max_attempts': 10},
|
||||
grader_type='Final Exam')
|
||||
|
||||
def test_status_removed_after_save_before_submit(self):
|
||||
"""
|
||||
Scenario: User should see the status removed when saving after submitting an answer and reloading the page.
|
||||
Given that I have loaded the problem page
|
||||
And a choice has been selected and submitted
|
||||
When I change the choice
|
||||
And Save the problem
|
||||
And reload the problem page
|
||||
Then I should see the save notification and I should not see any indication of problem status
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
problem_page.click_choice("choice_1")
|
||||
problem_page.click_submit()
|
||||
problem_page.wait_incorrect_notification()
|
||||
problem_page.wait_for_expected_status('label.choicegroup_incorrect', 'incorrect')
|
||||
problem_page.click_choice("choice_2")
|
||||
self.assertFalse(problem_page.is_expected_status_visible('label.choicegroup_incorrect'))
|
||||
problem_page.click_save()
|
||||
problem_page.wait_for_save_notification()
|
||||
# Refresh the page and the status should not be added
|
||||
self.courseware_page.visit()
|
||||
self.assertFalse(problem_page.is_expected_status_visible('label.choicegroup_incorrect'))
|
||||
self.assertTrue(problem_page.is_save_notification_visible())
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemSubmitButtonMaxAttemptsTest(ProblemsTest):
|
||||
"""
|
||||
Tests that the Submit button disables after the number of max attempts is reached.
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Problem structure.
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<label>Which of the following countries has the largest population?</label>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">Brazil <choicehint>timely feedback -- explain why an almost correct answer is wrong</choicehint></choice>
|
||||
<choice correct="false">Germany</choice>
|
||||
<choice correct="true">Indonesia</choice>
|
||||
<choice correct="false">Russia</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TEST PROBLEM', data=xml,
|
||||
metadata={'max_attempts': 2},
|
||||
grader_type='Final Exam')
|
||||
|
||||
def test_max_attempts(self):
|
||||
"""
|
||||
Verifies that the Submit button disables when the max number of attempts is reached.
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
|
||||
# Submit first answer (correct)
|
||||
problem_page.click_choice("choice_2")
|
||||
self.assertFalse(problem_page.is_submit_disabled())
|
||||
problem_page.click_submit()
|
||||
problem_page.wait_success_notification()
|
||||
|
||||
# Submit second and final answer (incorrect)
|
||||
problem_page.click_choice("choice_1")
|
||||
problem_page.click_submit()
|
||||
problem_page.wait_incorrect_notification()
|
||||
|
||||
# Make sure that the Submit button disables.
|
||||
problem_page.wait_for_submit_disabled()
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemSubmitButtonPastDueTest(ProblemsTest):
|
||||
"""
|
||||
Tests that the Submit button is disabled if it is past the due date.
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Problem structure.
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<label>Which of the following countries has the largest population?</label>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">Brazil <choicehint>timely feedback -- explain why an almost correct answer is wrong</choicehint></choice>
|
||||
<choice correct="false">Germany</choice>
|
||||
<choice correct="true">Indonesia</choice>
|
||||
<choice correct="false">Russia</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TEST PROBLEM', data=xml,
|
||||
metadata={'max_attempts': 2},
|
||||
grader_type='Final Exam')
|
||||
|
||||
def get_sequential(self):
|
||||
""" Subclasses can override this to add a sequential with metadata """
|
||||
return XBlockFixtureDesc('sequential', 'Test Subsection', metadata={'due': "2016-10-01T00"})
|
||||
|
||||
def test_past_due(self):
|
||||
"""
|
||||
Verifies that the Submit button disables when the max number of attempts is reached.
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
# Should have Submit button disabled on original rendering.
|
||||
problem_page.wait_for_submit_disabled()
|
||||
|
||||
# Select a choice, and make sure that the Submit button remains disabled.
|
||||
problem_page.click_choice("choice_2")
|
||||
problem_page.wait_for_submit_disabled()
|
||||
|
||||
|
||||
@attr(shard=19)
|
||||
class ProblemExtendedHintTest(ProblemHintTest, EventsTestMixin):
|
||||
"""
|
||||
Test that extended hint features plumb through to the page html and tracking log.
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Problem with extended hint features.
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<p>question text</p>
|
||||
<stringresponse answer="A">
|
||||
<stringequalhint answer="B">hint</stringequalhint>
|
||||
<textline size="20"/>
|
||||
</stringresponse>
|
||||
<demandhint>
|
||||
<hint>demand-hint1</hint>
|
||||
<hint>demand-hint2</hint>
|
||||
</demandhint>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TITLE', data=xml)
|
||||
|
||||
def test_check_hint(self):
|
||||
"""
|
||||
Test clicking Check shows the extended hint in the problem message.
|
||||
"""
|
||||
self.verify_check_hint(
|
||||
'B',
|
||||
u'Answer\nIncorrect: hint',
|
||||
[
|
||||
{
|
||||
'event':
|
||||
{
|
||||
'hint_label': u'Incorrect:',
|
||||
'trigger_type': 'single',
|
||||
'student_answer': [u'B'],
|
||||
'correctness': False,
|
||||
'question_type': 'stringresponse',
|
||||
'hints': [{'text': 'hint'}]
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
def test_demand_hint(self):
|
||||
"""
|
||||
Test clicking hint button shows the demand hint in its div.
|
||||
"""
|
||||
self.verify_demand_hints(
|
||||
u'Hint (1 of 2): demand-hint1',
|
||||
u'Hint (1 of 2): demand-hint1\nHint (2 of 2): demand-hint2',
|
||||
[
|
||||
{'event': {u'hint_index': 0, u'hint_len': 2, u'hint_text': u'demand-hint1'}},
|
||||
{'event': {u'hint_index': 1, u'hint_len': 2, u'hint_text': u'demand-hint2'}}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemHintWithHtmlTest(ProblemHintTest, EventsTestMixin):
|
||||
"""
|
||||
Tests that hints containing html get rendered properly
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Problem with extended hint features.
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<p>question text</p>
|
||||
<stringresponse answer="A">
|
||||
<stringequalhint answer="C"><a href="#">aa bb</a> cc</stringequalhint>
|
||||
<textline size="20"/>
|
||||
</stringresponse>
|
||||
<demandhint>
|
||||
<hint>aa <a href="#">bb</a> cc</hint>
|
||||
<hint><a href="#">dd ee</a> ff</hint>
|
||||
</demandhint>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'PROBLEM HTML HINT TEST', data=xml)
|
||||
|
||||
def test_check_hint(self):
|
||||
"""
|
||||
Test clicking Check shows the extended hint in the problem message.
|
||||
"""
|
||||
self.verify_check_hint(
|
||||
'C',
|
||||
u'Answer\nIncorrect: aa bb cc',
|
||||
[
|
||||
{
|
||||
'event':
|
||||
{
|
||||
'hint_label': u'Incorrect:',
|
||||
'trigger_type': 'single',
|
||||
'student_answer': [u'C'],
|
||||
'correctness': False,
|
||||
'question_type': 'stringresponse',
|
||||
'hints': [{'text': '<a href="#">aa bb</a> cc'}]
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
def test_demand_hint(self):
|
||||
"""
|
||||
Test clicking hint button shows the demand hints in a notification area.
|
||||
"""
|
||||
self.verify_demand_hints(
|
||||
u'Hint (1 of 2): aa bb cc',
|
||||
u'Hint (1 of 2): aa bb cc\nHint (2 of 2): dd ee ff',
|
||||
[
|
||||
{'event': {u'hint_index': 0, u'hint_len': 2, u'hint_text': u'aa <a href="#">bb</a> cc'}},
|
||||
{'event': {u'hint_index': 1, u'hint_len': 2, u'hint_text': u'<a href="#">dd ee</a> ff'}}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemWithMathjax(ProblemsTest):
|
||||
"""
|
||||
Tests the <MathJax> used in problem
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Create a problem with a <MathJax> in body and hint
|
||||
"""
|
||||
xml = dedent(r"""
|
||||
<problem>
|
||||
<p>Check mathjax has rendered [mathjax]E=mc^2[/mathjax]</p>
|
||||
<multiplechoiceresponse>
|
||||
<label>Answer this?</label>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="true">Choice1 <choicehint>Correct choice message</choicehint></choice>
|
||||
<choice correct="false">Choice2<choicehint>Wrong choice message</choicehint></choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
<demandhint>
|
||||
<hint>mathjax should work1 \(E=mc^2\) </hint>
|
||||
<hint>mathjax should work2 [mathjax]E=mc^2[/mathjax]</hint>
|
||||
</demandhint>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'MATHJAX TEST PROBLEM', data=xml)
|
||||
|
||||
def test_mathjax_in_hint(self):
|
||||
"""
|
||||
Test that MathJax have successfully rendered in problem hint
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
self.assertEqual(problem_page.problem_name, "MATHJAX TEST PROBLEM")
|
||||
|
||||
problem_page.verify_mathjax_rendered_in_problem()
|
||||
|
||||
# The hint button rotates through multiple hints
|
||||
problem_page.click_hint(hint_index=0)
|
||||
self.assertEqual(
|
||||
["<strong>Hint (1 of 2): </strong>mathjax should work1"],
|
||||
problem_page.extract_hint_text_from_html
|
||||
)
|
||||
problem_page.verify_mathjax_rendered_in_hint()
|
||||
|
||||
# Rotate the hint and check the problem hint
|
||||
problem_page.click_hint(hint_index=1)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"<strong>Hint (1 of 2): </strong>mathjax should work1",
|
||||
"<strong>Hint (2 of 2): </strong>mathjax should work2"
|
||||
],
|
||||
problem_page.extract_hint_text_from_html
|
||||
)
|
||||
|
||||
problem_page.verify_mathjax_rendered_in_hint()
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemPartialCredit(ProblemsTest):
|
||||
"""
|
||||
Makes sure that the partial credit is appearing properly.
|
||||
"""
|
||||
def get_problem(self):
|
||||
"""
|
||||
Create a problem with partial credit.
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<p>The answer is 1. Partial credit for -1.</p>
|
||||
<numericalresponse answer="1" partial_credit="list">
|
||||
<label>How many miles away from Earth is the sun? Use scientific notation to answer.</label>
|
||||
<formulaequationinput/>
|
||||
<responseparam type="tolerance" default="0.01" />
|
||||
<responseparam partial_answers="-1" />
|
||||
</numericalresponse>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'PARTIAL CREDIT TEST PROBLEM', data=xml)
|
||||
|
||||
# TODO: Reinstate this, it broke when landing the unified header in LEARNER-
|
||||
# def test_partial_credit(self):
|
||||
# """
|
||||
# Test that we can see the partial credit value and feedback.
|
||||
# """
|
||||
# self.courseware_page.visit()
|
||||
# problem_page = ProblemPage(self.browser)
|
||||
# self.assertEqual(problem_page.problem_name, 'PARTIAL CREDIT TEST PROBLEM')
|
||||
# problem_page.fill_answer_numerical('-1')
|
||||
# problem_page.click_submit()
|
||||
# problem_page.wait_for_status_icon()
|
||||
# self.assertTrue(problem_page.simpleprob_is_partially_correct())
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class LogoutDuringAnswering(ProblemsTest):
|
||||
"""
|
||||
Tests for the scenario where a user is logged out (their session expires
|
||||
or is revoked) just before they click "check" on a problem.
|
||||
"""
|
||||
def get_problem(self):
|
||||
"""
|
||||
Create a problem.
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<numericalresponse answer="1">
|
||||
<label>The answer is 1</label>
|
||||
<formulaequationinput/>
|
||||
<responseparam type="tolerance" default="0.01" />
|
||||
</numericalresponse>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TEST PROBLEM', data=xml)
|
||||
|
||||
def log_user_out(self):
|
||||
"""
|
||||
Log the user out by deleting their session cookie.
|
||||
"""
|
||||
self.browser.delete_cookie('sessionid')
|
||||
|
||||
def test_logout_after_click_redirect(self):
|
||||
"""
|
||||
1) User goes to a problem page.
|
||||
2) User fills out an answer to the problem.
|
||||
3) User is logged out because their session id is invalidated or removed.
|
||||
4) User clicks "check", and sees a confirmation modal asking them to
|
||||
re-authenticate, since they've just been logged out.
|
||||
5) User clicks "ok".
|
||||
6) User is redirected to the login page.
|
||||
7) User logs in.
|
||||
8) User is redirected back to the problem page they started out on.
|
||||
9) User is able to submit an answer
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
self.assertEqual(problem_page.problem_name, 'TEST PROBLEM')
|
||||
problem_page.fill_answer_numerical('1')
|
||||
|
||||
self.log_user_out()
|
||||
with problem_page.handle_alert(confirm=True):
|
||||
problem_page.click_submit()
|
||||
|
||||
login_page = CombinedLoginAndRegisterPage(self.browser)
|
||||
login_page.wait_for_page()
|
||||
|
||||
login_page.login(self.email, self.password)
|
||||
|
||||
problem_page.wait_for_page()
|
||||
self.assertEqual(problem_page.problem_name, 'TEST PROBLEM')
|
||||
|
||||
problem_page.fill_answer_numerical('1')
|
||||
problem_page.click_submit()
|
||||
self.assertTrue(problem_page.simpleprob_is_correct())
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ProblemQuestionDescriptionTest(ProblemsTest):
|
||||
"""TestCase Class to verify question and description rendering."""
|
||||
descriptions = [
|
||||
"A vegetable is an edible part of a plant in tuber form.",
|
||||
"A fruit is a fertilized ovary of a plant and contains seeds."
|
||||
]
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Create a problem with question and description.
|
||||
"""
|
||||
xml = dedent(u"""
|
||||
<problem>
|
||||
<choiceresponse>
|
||||
<label>Eggplant is a _____?</label>
|
||||
<description>{}</description>
|
||||
<description>{}</description>
|
||||
<checkboxgroup>
|
||||
<choice correct="true">vegetable</choice>
|
||||
<choice correct="false">fruit</choice>
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
</problem>
|
||||
""".format(*self.descriptions))
|
||||
return XBlockFixtureDesc('problem', 'Label with Description', data=xml)
|
||||
|
||||
def test_question_with_description(self):
|
||||
"""
|
||||
Scenario: Test that question and description are rendered as expected.
|
||||
Given I am enrolled in a course.
|
||||
When I visit a unit page with a CAPA question.
|
||||
Then label and description should be rendered correctly.
|
||||
"""
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
self.assertEqual(problem_page.problem_name, 'Label with Description')
|
||||
self.assertEqual(problem_page.problem_question, 'Eggplant is a _____?')
|
||||
self.assertEqual(problem_page.problem_question_descriptions, self.descriptions)
|
||||
|
||||
|
||||
class CAPAProblemA11yBaseTestMixin(object):
|
||||
"""Base TestCase Class to verify CAPA problem accessibility."""
|
||||
|
||||
@@ -877,325 +204,3 @@ class ProblemMathExpressionInputA11yTest(CAPAProblemA11yBaseTestMixin, ProblemsT
|
||||
</formularesponse>
|
||||
</problem>""")
|
||||
return XBlockFixtureDesc('problem', 'MATHEXPRESSIONINPUT PROBLEM', data=xml)
|
||||
|
||||
|
||||
class ProblemMetaGradedTest(ProblemsTest):
|
||||
"""
|
||||
TestCase Class to verify that the graded variable is passed
|
||||
"""
|
||||
shard = 23
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Problem structure
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<label>Which of the following countries has the largest population?</label>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">Brazil <choicehint>timely feedback -- explain why an almost correct answer is wrong</choicehint></choice>
|
||||
<choice correct="false">Germany</choice>
|
||||
<choice correct="true">Indonesia</choice>
|
||||
<choice correct="false">Russia</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TEST PROBLEM', data=xml, grader_type='Final Exam')
|
||||
|
||||
def test_grader_type_displayed(self):
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
self.assertEqual(problem_page.problem_name, 'TEST PROBLEM')
|
||||
self.assertEqual(problem_page.problem_progress_graded_value, "1 point possible (graded)")
|
||||
|
||||
|
||||
class ProblemMetaUngradedTest(ProblemsTest):
|
||||
"""
|
||||
TestCase Class to verify that the ungraded variable is passed
|
||||
"""
|
||||
shard = 23
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Problem structure
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<label>Which of the following countries has the largest population?</label>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">Brazil <choicehint>timely feedback -- explain why an almost correct answer is wrong</choicehint></choice>
|
||||
<choice correct="false">Germany</choice>
|
||||
<choice correct="true">Indonesia</choice>
|
||||
<choice correct="false">Russia</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TEST PROBLEM', data=xml)
|
||||
|
||||
def test_grader_type_displayed(self):
|
||||
self.courseware_page.visit()
|
||||
problem_page = ProblemPage(self.browser)
|
||||
self.assertEqual(problem_page.problem_name, 'TEST PROBLEM')
|
||||
self.assertEqual(problem_page.problem_progress_graded_value, "1 point possible (ungraded)")
|
||||
|
||||
|
||||
class FormulaProblemTest(ProblemsTest):
|
||||
"""
|
||||
Test Class to verify the formula problem on LMS.
|
||||
"""
|
||||
shard = 23
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup the test suite to verify various behaviors involving formula problem type.
|
||||
|
||||
Given a course, setup a formula problem type and view it in courseware
|
||||
Given the MathJax requirement for generating preview, wait for MathJax files to load
|
||||
"""
|
||||
super(FormulaProblemTest, self).setUp()
|
||||
self.courseware_page.visit()
|
||||
time.sleep(6)
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
creating the formula response problem, with reset button enabled.
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<formularesponse type="ci" samples="R_1,R_2,R_3@1,2,3:3,4,5#10" answer="R_1*R_2/R_3">
|
||||
<p>You can use this template as a guide to the OLX markup to use for math expression problems. Edit this component to replace the example with your own assessment.</p>
|
||||
<label>Add the question text, or prompt, here. This text is required. Example: Write an expression for the product of R_1, R_2, and the inverse of R_3.</label>
|
||||
<description>You can add an optional tip or note related to the prompt like this. Example: To test this example, the correct answer is R_1*R_2/R_3</description>
|
||||
<responseparam type="tolerance" default="0.00001"/>
|
||||
<formulaequationinput size="40"/>
|
||||
</formularesponse>
|
||||
</problem>
|
||||
""")
|
||||
return XBlockFixtureDesc('problem', 'TEST PROBLEM', data=xml, metadata={'show_reset_button': True})
|
||||
|
||||
def test_reset_button_not_rendered_after_correct_submission(self):
|
||||
"""
|
||||
Scenario: Verify that formula problem can not be resetted after an incorrect submission.
|
||||
|
||||
Given I am attempting a formula response problem type
|
||||
When I input a correct answer
|
||||
Then I should be able to see the mathjax generated preview
|
||||
When I submit the answer
|
||||
Then the correct status is visible
|
||||
And reset button is not rendered
|
||||
"""
|
||||
problem_page = ProblemPage(self.browser)
|
||||
problem_page.fill_answer_numerical('R_1*R_2/R_3')
|
||||
problem_page.verify_mathjax_rendered_in_preview()
|
||||
problem_page.click_submit()
|
||||
self.assertTrue(problem_page.simpleprob_is_correct())
|
||||
self.assertFalse(problem_page.is_reset_button_present())
|
||||
|
||||
def test_reset_problem_after_changing_correctness(self):
|
||||
"""
|
||||
Scenario: Verify that formula problem can be resetted after changing the correctness.
|
||||
|
||||
Given I am attempting a formula problem type
|
||||
When I answer it correctly
|
||||
Then the correctness status should be visible
|
||||
And reset button is not rendered
|
||||
When I change my submission to incorrect
|
||||
Then the reset button appears and is clickable
|
||||
"""
|
||||
problem_page = ProblemPage(self.browser)
|
||||
problem_page.fill_answer_numerical('R_1*R_2/R_3')
|
||||
problem_page.verify_mathjax_rendered_in_preview()
|
||||
problem_page.click_submit()
|
||||
self.assertTrue(problem_page.simpleprob_is_correct())
|
||||
self.assertFalse(problem_page.is_reset_button_present())
|
||||
problem_page.fill_answer_numerical('R_1/R_3')
|
||||
problem_page.click_submit()
|
||||
self.assertFalse(problem_page.simpleprob_is_correct())
|
||||
self.assertTrue(problem_page.is_reset_button_present())
|
||||
problem_page.click_reset()
|
||||
self.assertEqual(problem_page.get_numerical_input_value, '')
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class FormulaProblemRandomizeTest(ProblemsTest):
|
||||
"""
|
||||
Test Class to verify the formula problem on LMS with Randomization enabled.
|
||||
"""
|
||||
shard = 23
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup the test suite to verify various behaviors involving formula problem type.
|
||||
|
||||
Given a course, setup a formula problem type and view it in courseware
|
||||
Given the MathJax requirement for generating preview, wait for MathJax files to load
|
||||
"""
|
||||
super(FormulaProblemRandomizeTest, self).setUp()
|
||||
self.courseware_page.visit()
|
||||
time.sleep(6)
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
creating the formula response problem.
|
||||
"""
|
||||
xml = dedent("""
|
||||
<problem>
|
||||
<formularesponse type="ci" samples="R_1,R_2,R_3@1,2,3:3,4,5#10" answer="R_1*R_2/R_3">
|
||||
<p>You can use this template as a guide to the OLX markup to use for math expression problems. Edit this component to replace the example with your own assessment.</p>
|
||||
<label>Add the question text, or prompt, here. This text is required. Example: Write an expression for the product of R_1, R_2, and the inverse of R_3.</label>
|
||||
<description>You can add an optional tip or note related to the prompt like this. Example: To test this example, the correct answer is R_1*R_2/R_3</description>
|
||||
<responseparam type="tolerance" default="0.00001"/>
|
||||
<formulaequationinput size="40"/>
|
||||
</formularesponse>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
# rerandomize:always will show reset button, no matter the submission correctness
|
||||
return XBlockFixtureDesc(
|
||||
'problem', 'TEST PROBLEM', data=xml, metadata={'show_reset_button': True, 'rerandomize': 'always'}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
('R_1*R_2', 'incorrect', 'R_1*R_2/R_3'),
|
||||
('R_1*R_2/R_3', 'correct', 'R_1/R_3')
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_reset_correctness_after_changing_answer(self, input_value, correctness, next_input):
|
||||
"""
|
||||
Scenario: Test that formula problem can be resetted after changing the answer.
|
||||
|
||||
Given I am attempting a formula problem type with randomization:always configuration
|
||||
When I input an answer
|
||||
Then the mathjax generated preview should be visible
|
||||
When I submit the problem, I can see the correctness status
|
||||
When I only input another answer
|
||||
Then the correctness status is no longer visible
|
||||
And I am able to see the reset button
|
||||
And when I click the reset button
|
||||
Then input pane contents are cleared
|
||||
"""
|
||||
problem_page = ProblemPage(self.browser)
|
||||
problem_page.fill_answer_numerical(input_value)
|
||||
problem_page.verify_mathjax_rendered_in_preview()
|
||||
problem_page.click_submit()
|
||||
self.assertEqual(problem_page.get_simpleprob_correctness(), correctness)
|
||||
problem_page.fill_answer_numerical(next_input)
|
||||
self.assertIsNone(problem_page.get_simpleprob_correctness())
|
||||
self.assertTrue(problem_page.is_reset_button_present())
|
||||
problem_page.click_reset()
|
||||
self.assertEqual(problem_page.get_numerical_input_value, '')
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class DragAndDropXblockWithMixinsTest(UniqueCourseTest):
|
||||
"""
|
||||
Test Suite to verify various behaviors of DragAndDrop Xblock on the LMS.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(DragAndDropXblockWithMixinsTest, self).setUp()
|
||||
|
||||
self.username = "test_student_{uuid}".format(uuid=self.unique_id[0:8])
|
||||
self.email = "{username}@example.com".format(username=self.username)
|
||||
self.password = "keep it secret; keep it safe."
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
|
||||
# Install a course with a hierarchy and problems
|
||||
self.course_fixture = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name'],
|
||||
start_date=datetime.now() + timedelta(days=10)
|
||||
)
|
||||
self.browser.set_window_size(1024, 1024)
|
||||
|
||||
def setup_sequential(self, metadata):
|
||||
"""
|
||||
Setup a sequential with DnD problem, alongwith the metadata provided.
|
||||
|
||||
This method will allow to customize the sequential, such as changing the
|
||||
due date for individual tests.
|
||||
"""
|
||||
problem = self.get_problem()
|
||||
sequential = self.get_sequential(metadata=metadata)
|
||||
self.course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
sequential.add_children(problem)
|
||||
)
|
||||
).install()
|
||||
|
||||
# Auto-auth register for the course.
|
||||
AutoAuthPage(
|
||||
self.browser,
|
||||
username=self.username,
|
||||
email=self.email,
|
||||
password=self.password,
|
||||
course_id=self.course_id,
|
||||
staff=True
|
||||
).visit()
|
||||
|
||||
def format_date(self, date_value):
|
||||
"""
|
||||
Get the date in isoformat as this is required format to add date data
|
||||
in the sequential.
|
||||
"""
|
||||
return date_value.isoformat()
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Creating a DnD problem with assessment mode
|
||||
"""
|
||||
return XBlockFixtureDesc('drag-and-drop-v2', 'DnD', metadata={'mode': "assessment"})
|
||||
|
||||
def get_sequential(self, metadata=None):
|
||||
return XBlockFixtureDesc('sequential', 'Test Subsection', metadata=metadata)
|
||||
|
||||
@ddt.data(
|
||||
(datetime.now(), True),
|
||||
(datetime.now() - timedelta(days=1), True),
|
||||
(datetime.now() + timedelta(days=1), False)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_submit_button_status_with_due_date(self, due_date, is_button_disabled):
|
||||
"""
|
||||
Scenario: Test that DnD submit button will be enabled if section is not past due.
|
||||
|
||||
Given I have a sequential in instructor-paced course
|
||||
And a DnD problem with assessment mode is present in the sequential
|
||||
When I visit the problem
|
||||
Then the submit button should be present
|
||||
And button should be disabled as some item needs to be on a zone
|
||||
When I drag an item to a zone
|
||||
Then submit button will be enabled if due date has not passed, else disabled
|
||||
"""
|
||||
problem_page = DragAndDropPage(self.browser)
|
||||
self.setup_sequential(metadata={'due': self.format_date(due_date)})
|
||||
self.courseware_page.visit()
|
||||
self.assertTrue(problem_page.is_submit_button_present())
|
||||
self.assertTrue(problem_page.is_submit_disabled())
|
||||
problem_page.drag_item_to_zone(0, 'middle')
|
||||
self.assertEqual(is_button_disabled, problem_page.is_submit_disabled())
|
||||
|
||||
def test_submit_button_when_pacing_change_self_paced(self):
|
||||
"""
|
||||
Scenario: For a self-paced course, the submit button of DnD problems will be
|
||||
be enabled, regardless of the subsection due date.
|
||||
|
||||
Given a DnD problem in a subsection with past due date
|
||||
And the course is instructor-paced
|
||||
Then the submit button will remain disabled after initial drag
|
||||
When the pacing is changed to self-paced
|
||||
Then the submit button is not disabled anymore
|
||||
"""
|
||||
problem_page = DragAndDropPage(self.browser)
|
||||
self.setup_sequential(metadata={'due': self.format_date(datetime.now())})
|
||||
self.courseware_page.visit()
|
||||
problem_page.drag_item_to_zone(0, 'middle')
|
||||
self.assertTrue(problem_page.is_submit_disabled())
|
||||
self.course_fixture.add_course_details({'self_paced': True})
|
||||
self.course_fixture.configure_course()
|
||||
self.courseware_page.visit()
|
||||
self.assertFalse(problem_page.is_submit_disabled())
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
"""
|
||||
Test courseware search
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
|
||||
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.common.logout import LogoutPage
|
||||
from common.test.acceptance.pages.lms.course_home import CourseHomePage
|
||||
from common.test.acceptance.pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
|
||||
from common.test.acceptance.tests.helpers import create_user_partition_json, remove_file
|
||||
from common.test.acceptance.tests.studio.base_studio_test import ContainerBase
|
||||
from xmodule.partitions.partitions import Group
|
||||
|
||||
|
||||
class SplitTestCoursewareSearchTest(ContainerBase):
|
||||
"""
|
||||
Test courseware search on Split Test Module.
|
||||
"""
|
||||
shard = 1
|
||||
USERNAME = 'STUDENT_TESTER'
|
||||
EMAIL = 'student101@example.com'
|
||||
|
||||
TEST_INDEX_FILENAME = "test_root/index_file.dat"
|
||||
|
||||
def setUp(self, is_staff=True):
|
||||
"""
|
||||
Create search page and course content to search
|
||||
"""
|
||||
# create test file in which index for this test will live
|
||||
with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
|
||||
json.dump({}, index_file)
|
||||
self.addCleanup(remove_file, self.TEST_INDEX_FILENAME)
|
||||
|
||||
super(SplitTestCoursewareSearchTest, self).setUp(is_staff=is_staff)
|
||||
self.staff_user = self.user
|
||||
|
||||
self.course_home_page = CourseHomePage(self.browser, self.course_id)
|
||||
self.studio_course_outline = StudioCourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
self._create_group_configuration()
|
||||
self._studio_reindex()
|
||||
|
||||
def _auto_auth(self, username, email, staff):
|
||||
"""
|
||||
Logout and login with given credentials.
|
||||
"""
|
||||
LogoutPage(self.browser).visit()
|
||||
AutoAuthPage(self.browser, username=username, email=email, course_id=self.course_id, staff=staff).visit()
|
||||
|
||||
def _studio_reindex(self):
|
||||
"""
|
||||
Reindex course content on studio course page
|
||||
"""
|
||||
self._auto_auth(self.staff_user["username"], self.staff_user["email"], True)
|
||||
self.studio_course_outline.visit()
|
||||
self.studio_course_outline.start_reindex()
|
||||
self.studio_course_outline.wait_for_ajax()
|
||||
|
||||
def _create_group_configuration(self):
|
||||
"""
|
||||
Create a group configuration for course
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
create_user_partition_json(
|
||||
0,
|
||||
"Configuration A/B",
|
||||
"Content Group Partition.",
|
||||
[Group("0", "Group A"), Group("1", "Group B")]
|
||||
)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Populate the children of the test course fixture.
|
||||
"""
|
||||
course_fixture.add_advanced_settings({
|
||||
u"advanced_modules": {"value": ["split_test"]},
|
||||
})
|
||||
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc(
|
||||
'html',
|
||||
'VISIBLE TO A',
|
||||
data='<html>VISIBLE TO A</html>',
|
||||
metadata={"group_access": {0: [0]}}
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
'html',
|
||||
'VISIBLE TO B',
|
||||
data='<html>VISIBLE TO B</html>',
|
||||
metadata={"group_access": {0: [1]}}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_search_for_experiment_content_user_assigned_to_one_group(self):
|
||||
"""
|
||||
Test user can search for experiment content restricted to his group
|
||||
when assigned to just one experiment group
|
||||
"""
|
||||
self._auto_auth(self.USERNAME, self.EMAIL, False)
|
||||
self.course_home_page.visit()
|
||||
course_search_results_page = self.course_home_page.search_for_term("VISIBLE TO")
|
||||
assert "result-excerpt" in course_search_results_page.search_results.html[0]
|
||||
@@ -9,7 +9,6 @@ from textwrap import dedent
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage
|
||||
from common.test.acceptance.pages.lms.staff_view import StaffCoursewarePage
|
||||
from common.test.acceptance.tests.helpers import UniqueCourseTest, create_user_partition_json
|
||||
from openedx.core.lib.tests import attr
|
||||
@@ -54,54 +53,6 @@ class StaffViewTest(UniqueCourseTest):
|
||||
return staff_page
|
||||
|
||||
|
||||
@attr(shard=20)
|
||||
class CourseWithoutContentGroupsTest(StaffViewTest):
|
||||
"""
|
||||
Setup for tests that have no content restricted to specific content groups.
|
||||
"""
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Populates test course with chapter, sequential, and 2 problems.
|
||||
"""
|
||||
problem_data = dedent("""
|
||||
<problem markdown="Simple Problem" max_attempts="" weight="">
|
||||
<p>Choose Yes.</p>
|
||||
<choiceresponse>
|
||||
<checkboxgroup>
|
||||
<choice correct="true">Yes</choice>
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('problem', 'Test Problem 1', data=problem_data),
|
||||
XBlockFixtureDesc('problem', 'Test Problem 2', data=problem_data)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@attr(shard=20)
|
||||
class StaffViewToggleTest(CourseWithoutContentGroupsTest):
|
||||
"""
|
||||
Tests for the staff view toggle button.
|
||||
"""
|
||||
def test_instructor_tab_visibility(self):
|
||||
"""
|
||||
Test that the instructor tab is hidden when viewing as a student.
|
||||
"""
|
||||
|
||||
course_page = self._goto_staff_page()
|
||||
self.assertTrue(course_page.has_tab('Instructor'))
|
||||
course_page.set_staff_view_mode('Learner')
|
||||
self.assertEqual(course_page.staff_view_mode, 'Learner')
|
||||
self.assertFalse(course_page.has_tab('Instructor'))
|
||||
|
||||
|
||||
@attr(shard=20)
|
||||
class CourseWithContentGroupsTest(StaffViewTest):
|
||||
"""
|
||||
@@ -183,91 +134,6 @@ class CourseWithContentGroupsTest(StaffViewTest):
|
||||
)
|
||||
)
|
||||
|
||||
def test_staff_sees_all_problems(self):
|
||||
"""
|
||||
Scenario: Staff see all problems
|
||||
Given I have a course with a cohort user partition
|
||||
And problems that are associated with specific groups in the user partition
|
||||
When I view the courseware in the LMS with staff access
|
||||
Then I see all the problems, regardless of their group_access property
|
||||
"""
|
||||
course_page = self._goto_staff_page()
|
||||
verify_expected_problem_visibility(
|
||||
self,
|
||||
course_page,
|
||||
[self.alpha_text, self.beta_text, self.audit_text, self.everyone_text]
|
||||
)
|
||||
|
||||
def test_student_not_in_content_group(self):
|
||||
"""
|
||||
Scenario: When previewing as a learner, only content visible to all is shown
|
||||
Given I have a course with a cohort user partition
|
||||
And problems that are associated with specific groups in the user partition
|
||||
When I view the courseware in the LMS with staff access
|
||||
And I change to previewing as a Learner
|
||||
Then I see only problems visible to all users
|
||||
"""
|
||||
course_page = self._goto_staff_page()
|
||||
course_page.set_staff_view_mode('Learner')
|
||||
verify_expected_problem_visibility(self, course_page, [self.everyone_text])
|
||||
|
||||
def test_as_student_in_alpha(self):
|
||||
"""
|
||||
Scenario: When previewing as a learner in group alpha, only content visible to alpha is shown
|
||||
Given I have a course with a cohort user partition
|
||||
And problems that are associated with specific groups in the user partition
|
||||
When I view the courseware in the LMS with staff access
|
||||
And I change to previewing as a Learner in group alpha
|
||||
Then I see only problems visible to group alpha
|
||||
"""
|
||||
course_page = self._goto_staff_page()
|
||||
course_page.set_staff_view_mode('Learner in alpha')
|
||||
verify_expected_problem_visibility(self, course_page, [self.alpha_text, self.everyone_text])
|
||||
|
||||
def test_as_student_in_beta(self):
|
||||
"""
|
||||
Scenario: When previewing as a learner in group beta, only content visible to beta is shown
|
||||
Given I have a course with a cohort user partition
|
||||
And problems that are associated with specific groups in the user partition
|
||||
When I view the courseware in the LMS with staff access
|
||||
And I change to previewing as a Learner in group beta
|
||||
Then I see only problems visible to group beta
|
||||
"""
|
||||
course_page = self._goto_staff_page()
|
||||
course_page.set_staff_view_mode('Learner in beta')
|
||||
verify_expected_problem_visibility(self, course_page, [self.beta_text, self.everyone_text])
|
||||
|
||||
def test_as_student_in_audit(self):
|
||||
"""
|
||||
Scenario: When previewing as a learner in the audit enrollment track, only content visible to audit is shown
|
||||
Given I have a course with an enrollment_track user partition
|
||||
And problems that are associated with specific groups in the user partition
|
||||
When I view the courseware in the LMS with staff access
|
||||
And I change to previewing as a Learner in audit enrollment track
|
||||
Then I see only problems visible to audit enrollment track
|
||||
"""
|
||||
course_page = self._goto_staff_page()
|
||||
course_page.set_staff_view_mode('Learner in Audit')
|
||||
verify_expected_problem_visibility(self, course_page, [self.audit_text, self.everyone_text])
|
||||
|
||||
def create_cohorts_and_assign_students(self, student_a_username, student_b_username):
|
||||
"""
|
||||
Adds 2 manual cohorts, linked to content groups, to the course.
|
||||
Each cohort is assigned one learner.
|
||||
"""
|
||||
instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id)
|
||||
instructor_dashboard_page.visit()
|
||||
cohort_management_page = instructor_dashboard_page.select_cohort_management()
|
||||
cohort_management_page.is_cohorted = True
|
||||
|
||||
def add_cohort_with_student(cohort_name, content_group, student):
|
||||
""" Create cohort and assign learner to it. """
|
||||
cohort_management_page.add_cohort(cohort_name, content_group=content_group)
|
||||
cohort_management_page.add_students_to_selected_cohort([student])
|
||||
add_cohort_with_student("Cohort Alpha", "alpha", student_a_username)
|
||||
add_cohort_with_student("Cohort Beta", "beta", student_b_username)
|
||||
cohort_management_page.wait_for_ajax()
|
||||
|
||||
@attr('a11y')
|
||||
def test_course_page(self):
|
||||
"""
|
||||
@@ -286,14 +152,3 @@ class CourseWithContentGroupsTest(StaffViewTest):
|
||||
]
|
||||
})
|
||||
course_page.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
|
||||
def verify_expected_problem_visibility(test, courseware_page, expected_problems):
|
||||
"""
|
||||
Helper method that checks that the expected problems are visible on the current page.
|
||||
"""
|
||||
courseware_page.wait_for(
|
||||
lambda: courseware_page.num_xblock_components == len(expected_problems), "Expected number of problems visible"
|
||||
)
|
||||
for index, expected_problem in enumerate(expected_problems):
|
||||
test.assertIn(expected_problem, courseware_page.xblock_components[index].text)
|
||||
|
||||
@@ -8,10 +8,8 @@ import textwrap
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
import ddt
|
||||
import pytest
|
||||
import six
|
||||
from bok_choy.promise import BrokenPromise
|
||||
from six.moves import range
|
||||
|
||||
from capa.tests.response_xml_factory import (
|
||||
AnnotationResponseXMLFactory,
|
||||
@@ -179,377 +177,6 @@ class ProblemTypeA11yTestMixin(object):
|
||||
self.problem_page.a11y_audit.check_for_accessibility_errors()
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ProblemTypeTestMixin(ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Test cases shared amongst problem types.
|
||||
"""
|
||||
can_submit_blank = False
|
||||
can_update_save_notification = True
|
||||
|
||||
@attr(shard=11)
|
||||
def test_answer_correctly(self):
|
||||
"""
|
||||
Scenario: I can answer a problem correctly
|
||||
Given External graders respond "correct"
|
||||
And I am viewing a "<ProblemType>" problem
|
||||
When I answer a "<ProblemType>" problem "correctly"
|
||||
Then my "<ProblemType>" answer is marked "correct"
|
||||
And The "<ProblemType>" problem displays a "correct" answer
|
||||
And a success notification is shown
|
||||
And clicking on "Review" moves focus to the problem meta area
|
||||
And a "problem_check" server event is emitted
|
||||
And a "problem_check" browser event is emitted
|
||||
"""
|
||||
# Make sure we're looking at the right problem
|
||||
self.problem_page.wait_for(
|
||||
lambda: self.problem_page.problem_name == self.problem_name,
|
||||
"Make sure the correct problem is on the page"
|
||||
)
|
||||
|
||||
# Answer the problem correctly
|
||||
self.answer_problem(correctness='correct')
|
||||
self.problem_page.click_submit()
|
||||
self.wait_for_status('correct')
|
||||
self.problem_page.wait_success_notification()
|
||||
# Check that clicking on "Review" goes to the problem meta location
|
||||
self.problem_page.click_review_in_notification(notification_type='submit')
|
||||
self.problem_page.wait_for_focus_on_problem_meta()
|
||||
|
||||
# Check for corresponding tracking event
|
||||
expected_events = [
|
||||
{
|
||||
'event_source': 'server',
|
||||
'event_type': 'problem_check',
|
||||
'username': self.username,
|
||||
}, {
|
||||
'event_source': 'browser',
|
||||
'event_type': 'problem_check',
|
||||
'username': self.username,
|
||||
},
|
||||
]
|
||||
|
||||
for event in expected_events:
|
||||
self.wait_for_events(event_filter=event, number_of_matches=1)
|
||||
|
||||
@attr(shard=11)
|
||||
def test_answer_incorrectly(self):
|
||||
"""
|
||||
Scenario: I can answer a problem incorrectly
|
||||
Given External graders respond "incorrect"
|
||||
And I am viewing a "<ProblemType>" problem
|
||||
When I answer a "<ProblemType>" problem "incorrectly"
|
||||
Then my "<ProblemType>" answer is marked "incorrect"
|
||||
And The "<ProblemType>" problem displays a "incorrect" answer
|
||||
"""
|
||||
self.problem_page.wait_for(
|
||||
lambda: self.problem_page.problem_name == self.problem_name,
|
||||
"Make sure the correct problem is on the page"
|
||||
)
|
||||
|
||||
# Answer the problem incorrectly
|
||||
self.answer_problem(correctness='incorrect')
|
||||
self.problem_page.click_submit()
|
||||
self.wait_for_status('incorrect')
|
||||
self.problem_page.wait_incorrect_notification()
|
||||
|
||||
@attr(shard=11)
|
||||
def test_submit_blank_answer(self):
|
||||
"""
|
||||
Scenario: I can submit a blank answer
|
||||
Given I am viewing a "<ProblemType>" problem
|
||||
When I submit a problem
|
||||
Then my "<ProblemType>" answer is marked "incorrect"
|
||||
And The "<ProblemType>" problem displays a "blank" answer
|
||||
"""
|
||||
if not self.can_submit_blank:
|
||||
pytest.skip("Test incompatible with the current problem type")
|
||||
|
||||
self.problem_page.wait_for(
|
||||
lambda: self.problem_page.problem_name == self.problem_name,
|
||||
"Make sure the correct problem is on the page"
|
||||
)
|
||||
# Leave the problem unchanged and assure submit is disabled.
|
||||
self.wait_for_status('unanswered')
|
||||
self.assertFalse(self.problem_page.is_submit_disabled())
|
||||
self.problem_page.click_submit()
|
||||
self.wait_for_status('incorrect')
|
||||
|
||||
@attr(shard=11)
|
||||
def test_cant_submit_blank_answer(self):
|
||||
"""
|
||||
Scenario: I can't submit a blank answer
|
||||
When I try to submit blank answer
|
||||
Then I can't submit a problem
|
||||
"""
|
||||
if self.can_submit_blank:
|
||||
pytest.skip("Test incompatible with the current problem type")
|
||||
|
||||
self.problem_page.wait_for(
|
||||
lambda: self.problem_page.problem_name == self.problem_name,
|
||||
"Make sure the correct problem is on the page"
|
||||
)
|
||||
self.assertTrue(self.problem_page.is_submit_disabled())
|
||||
|
||||
@attr(shard=12)
|
||||
def test_can_show_answer(self):
|
||||
"""
|
||||
Scenario: Verifies that show answer button is working as expected.
|
||||
|
||||
Given that I am on courseware page
|
||||
And I can see a CAPA problem with show answer button
|
||||
When I click "Show Answer" button
|
||||
And I should see question's solution
|
||||
And I should see the problem title is focused
|
||||
"""
|
||||
self.problem_page.click_show()
|
||||
self.problem_page.wait_for_show_answer_notification()
|
||||
|
||||
@attr(shard=12)
|
||||
def test_save_reaction(self):
|
||||
"""
|
||||
Scenario: Verify that the save button performs as expected with problem types
|
||||
|
||||
Given that I am on a problem page
|
||||
And I can see a CAPA problem with the Save button present
|
||||
When I select and answer and click the "Save" button
|
||||
Then I should see the Save notification
|
||||
And the Save button should not be disabled
|
||||
And clicking on "Review" moves focus to the problem meta area
|
||||
And if I change the answer selected
|
||||
Then the Save notification should be removed
|
||||
"""
|
||||
self.problem_page.wait_for(
|
||||
lambda: self.problem_page.problem_name == self.problem_name,
|
||||
"Make sure the correct problem is on the page"
|
||||
)
|
||||
self.problem_page.wait_for_page()
|
||||
self.answer_problem(correctness='correct')
|
||||
self.assertTrue(self.problem_page.is_save_button_enabled())
|
||||
self.problem_page.click_save()
|
||||
# Ensure "Save" button is enabled after save is complete.
|
||||
self.assertTrue(self.problem_page.is_save_button_enabled())
|
||||
self.problem_page.wait_for_save_notification()
|
||||
# Check that clicking on "Review" goes to the problem meta location
|
||||
self.problem_page.click_review_in_notification(notification_type='save')
|
||||
self.problem_page.wait_for_focus_on_problem_meta()
|
||||
|
||||
# Not all problems will detect the change and remove the save notification
|
||||
if self.can_update_save_notification:
|
||||
self.answer_problem(correctness='incorrect')
|
||||
self.assertFalse(self.problem_page.is_save_notification_visible())
|
||||
|
||||
@attr(shard=12)
|
||||
def test_reset_shows_errors(self):
|
||||
"""
|
||||
Scenario: Reset will show server errors
|
||||
If I reset a problem without first answering it
|
||||
Then a "gentle notification" is shown
|
||||
And the focus moves to the "gentle notification"
|
||||
"""
|
||||
self.problem_page.wait_for(
|
||||
lambda: self.problem_page.problem_name == self.problem_name,
|
||||
"Make sure the correct problem is on the page"
|
||||
)
|
||||
self.wait_for_status('unanswered')
|
||||
self.assertFalse(self.problem_page.is_gentle_alert_notification_visible())
|
||||
# Click reset without first answering the problem (possible because show_reset_button is set to True)
|
||||
self.problem_page.click_reset()
|
||||
self.problem_page.wait_for_gentle_alert_notification()
|
||||
|
||||
@attr(shard=12)
|
||||
def test_partially_complete_notifications(self):
|
||||
"""
|
||||
Scenario: If a partially correct problem is submitted the correct notification is shown
|
||||
If I submit an answer that is partially correct
|
||||
Then the partially correct notification should be shown
|
||||
"""
|
||||
|
||||
# Not all problems have partially correct solutions configured
|
||||
if not self.partially_correct:
|
||||
pytest.skip("Test incompatible with the current problem type")
|
||||
|
||||
self.problem_page.wait_for(
|
||||
lambda: self.problem_page.problem_name == self.problem_name,
|
||||
"Make sure the correct problem is on the page"
|
||||
)
|
||||
|
||||
self.wait_for_status('unanswered')
|
||||
# Set an answer
|
||||
self.answer_problem(correctness='partially-correct')
|
||||
self.problem_page.click_submit()
|
||||
self.problem_page.wait_partial_notification()
|
||||
|
||||
@ddt.data('correct', 'incorrect')
|
||||
def test_reset_problem(self, correctness):
|
||||
"""
|
||||
Scenario: I can reset a problem
|
||||
|
||||
Given I am viewing a problem with randomization: always and with reset button: on
|
||||
And I answer a problem as <correctness>
|
||||
When I reset the problem
|
||||
Then my answer is marked "unanswered"
|
||||
And The problem displays a "blank" answer
|
||||
"""
|
||||
self.answer_problem(correctness)
|
||||
self.problem_page.click_submit()
|
||||
self.problem_page.click_reset()
|
||||
self.assertTrue(self.problem_status('unanswered'))
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ChangingAnswerOfProblemTestMixin(object):
|
||||
"""
|
||||
Test the effect of changing the answers of problem
|
||||
"""
|
||||
|
||||
@ddt.data(['correct', '1/1 point (ungraded)'], ['incorrect', '0/1 point (ungraded)'])
|
||||
@ddt.unpack
|
||||
def test_checkbox_score_after_answer_and_reset(self, correctness, score):
|
||||
"""
|
||||
Scenario: I can see my score on problem when I answer it and after I reset it
|
||||
|
||||
Given I am viewing problem
|
||||
When I answer problem with <correctness>
|
||||
Then I should see a <score>
|
||||
When I reset the problem
|
||||
Then I should see a score of points possible: 0/1 point (ungraded)
|
||||
"""
|
||||
self.answer_problem(correctness)
|
||||
self.problem_page.click_submit()
|
||||
self.assertEqual(self.problem_page.problem_progress_graded_value, score)
|
||||
self.problem_page.click_reset()
|
||||
self.assertEqual(self.problem_page.problem_progress_graded_value, '0/1 point (ungraded)')
|
||||
|
||||
@ddt.data(['correct', 'incorrect'], ['incorrect', 'correct'])
|
||||
@ddt.unpack
|
||||
def test_reset_correctness_after_changing_answer(self, initial_correctness, other_correctness):
|
||||
"""
|
||||
Scenario: I can reset the correctness of a problem after changing my answer
|
||||
|
||||
Given I am viewing problem
|
||||
Then my problem's answer is marked "unanswered"
|
||||
When I answer and submit the problem with <initial correctness>
|
||||
Then my problem's answer is marked with <initial correctness>
|
||||
And I input an answer as <other correctness>
|
||||
Then my problem's answer is marked "unanswered"
|
||||
"""
|
||||
self.assertTrue(self.problem_status('unanswered'))
|
||||
self.answer_problem(initial_correctness)
|
||||
self.problem_page.click_submit()
|
||||
|
||||
self.assertTrue(self.problem_status(initial_correctness))
|
||||
|
||||
self.answer_problem(other_correctness)
|
||||
self.assertTrue(self.problem_status('unanswered'))
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class NonRandomizedProblemTypeTestMixin(ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Test the effect of 'randomization: never'
|
||||
"""
|
||||
can_submit_blank = False
|
||||
can_update_save_notification = True
|
||||
|
||||
def test_non_randomized_problem_correctly(self):
|
||||
"""
|
||||
Scenario: The reset button doesn't show up
|
||||
|
||||
Given I am viewing a problem with "randomization": never and with "reset button": on
|
||||
And I answer problem problem problem correctly
|
||||
Then The "Reset" button does not appear
|
||||
"""
|
||||
self.answer_problem("correct")
|
||||
self.problem_page.click_submit()
|
||||
self.assertFalse(self.problem_page.is_reset_button_present())
|
||||
|
||||
def test_non_randomized_problem_incorrectly(self):
|
||||
"""
|
||||
Scenario: I can reset a non-randomized problem that I answered incorrectly
|
||||
|
||||
Given I am viewing problem with "randomization": never and with "reset button": on
|
||||
And I answer problem incorrectly
|
||||
When I reset the problem
|
||||
Then my problem answer is marked "unanswered"
|
||||
And the problem problem displays a "blank" answer
|
||||
"""
|
||||
self.answer_problem("incorrect")
|
||||
self.problem_page.click_submit()
|
||||
self.problem_page.click_reset()
|
||||
self.assertTrue(self.problem_status('unanswered'))
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ProblemNeverShowCorrectnessMixin(object):
|
||||
"""
|
||||
Tests the effect of adding `show_correctness: never` to the sequence metadata
|
||||
for subclasses of ProblemTypeTestMixin.
|
||||
"""
|
||||
sequential_metadata = {'show_correctness': 'never'}
|
||||
|
||||
@attr(shard=7)
|
||||
@ddt.data('correct', 'incorrect', 'partially-correct')
|
||||
def test_answer_says_submitted(self, correctness):
|
||||
"""
|
||||
Scenario: I can answer a problem <Correctness>ly
|
||||
Given External graders respond "<Correctness>"
|
||||
And I am viewing a "<ProblemType>" problem
|
||||
in a subsection with show_correctness set to "never"
|
||||
Then I should see a score of "N point(s) possible (ungraded, results hidden)"
|
||||
When I answer a "<ProblemType>" problem "<Correctness>ly"
|
||||
And the "<ProblemType>" problem displays only a "submitted" notification.
|
||||
And I should see a score of "N point(s) possible (ungraded, results hidden)"
|
||||
And a "problem_check" server event is emitted
|
||||
And a "problem_check" browser event is emitted
|
||||
"""
|
||||
|
||||
# Not all problems have partially correct solutions configured
|
||||
if correctness == 'partially-correct' and not self.partially_correct:
|
||||
pytest.skip("Test incompatible with the current problem type")
|
||||
|
||||
# Problem progress text depends on points possible
|
||||
possible = 'possible (ungraded, results hidden)'
|
||||
if self.problem_points == 1:
|
||||
problem_progress = u'1 point {}'.format(possible)
|
||||
else:
|
||||
problem_progress = u'{} points {}'.format(self.problem_points, possible)
|
||||
|
||||
# Make sure we're looking at the right problem
|
||||
self.problem_page.wait_for(
|
||||
lambda: self.problem_page.problem_name == self.problem_name,
|
||||
"Make sure the correct problem is on the page"
|
||||
)
|
||||
|
||||
# Learner can see that score will be hidden prior to submitting answer
|
||||
self.assertEqual(self.problem_page.problem_progress_graded_value, problem_progress)
|
||||
|
||||
# Answer the problem correctly
|
||||
self.answer_problem(correctness=correctness)
|
||||
self.problem_page.click_submit()
|
||||
self.wait_for_status('submitted')
|
||||
self.problem_page.wait_submitted_notification()
|
||||
|
||||
# Score is still hidden after submitting answer
|
||||
self.assertEqual(self.problem_page.problem_progress_graded_value, problem_progress)
|
||||
|
||||
# Check for corresponding tracking event
|
||||
expected_events = [
|
||||
{
|
||||
'event_source': 'server',
|
||||
'event_type': 'problem_check',
|
||||
'username': self.username,
|
||||
}, {
|
||||
'event_source': 'browser',
|
||||
'event_type': 'problem_check',
|
||||
'username': self.username,
|
||||
},
|
||||
]
|
||||
|
||||
for event in expected_events:
|
||||
self.wait_for_events(event_filter=event, number_of_matches=1)
|
||||
|
||||
|
||||
class AnnotationProblemTypeBase(ProblemTypeTestBase):
|
||||
"""
|
||||
ProblemTypeTestBase specialization for Annotation Problem Type
|
||||
@@ -615,7 +242,7 @@ class AnnotationProblemTypeBase(ProblemTypeTestBase):
|
||||
).nth(choice).click()
|
||||
|
||||
|
||||
class AnnotationProblemTypeTest(AnnotationProblemTypeBase, ProblemTypeTestMixin):
|
||||
class AnnotationProblemTypeTest(AnnotationProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the Annotation Problem Type
|
||||
"""
|
||||
@@ -623,13 +250,6 @@ class AnnotationProblemTypeTest(AnnotationProblemTypeBase, ProblemTypeTestMixin)
|
||||
pass
|
||||
|
||||
|
||||
class AnnotationProblemTypeNeverShowCorrectnessTest(AnnotationProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Annotation Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CheckboxProblemTypeBase(ProblemTypeTestBase):
|
||||
"""
|
||||
ProblemTypeTestBase specialization Checkbox Problem Type
|
||||
@@ -664,29 +284,14 @@ class CheckboxProblemTypeBase(ProblemTypeTestBase):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CheckboxProblemTypeTest(CheckboxProblemTypeBase, ProblemTypeTestMixin, ChangingAnswerOfProblemTestMixin):
|
||||
class CheckboxProblemTypeTest(CheckboxProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the Checkbox Problem Type
|
||||
"""
|
||||
shard = 18
|
||||
|
||||
def test_can_show_answer(self):
|
||||
"""
|
||||
Scenario: Verifies that show answer button is working as expected.
|
||||
|
||||
Given that I am on courseware page
|
||||
And I can see a CAPA problem with show answer button
|
||||
When I click "Show Answer" button
|
||||
And I should see question's solution
|
||||
And I should see correct choices highlighted
|
||||
"""
|
||||
self.problem_page.click_show()
|
||||
self.assertTrue(self.problem_page.is_solution_tag_present())
|
||||
self.assertTrue(self.problem_page.is_correct_choice_highlighted(correct_choices=[1, 3]))
|
||||
self.problem_page.wait_for_show_answer_notification()
|
||||
|
||||
|
||||
class CheckboxProblemTypeTestNonRandomized(CheckboxProblemTypeBase, NonRandomizedProblemTypeTestMixin):
|
||||
class CheckboxProblemTypeTestNonRandomized(CheckboxProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Tests for the non-randomized checkbox problem
|
||||
"""
|
||||
@@ -704,13 +309,6 @@ class CheckboxProblemTypeTestNonRandomized(CheckboxProblemTypeBase, NonRandomize
|
||||
)
|
||||
|
||||
|
||||
class CheckboxProblemTypeNeverShowCorrectnessTest(CheckboxProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Checkbox Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class MultipleChoiceProblemTypeBase(ProblemTypeTestBase):
|
||||
"""
|
||||
@@ -763,92 +361,15 @@ class MultipleChoiceProblemTypeBase(ProblemTypeTestBase):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class MultipleChoiceProblemTypeTest(MultipleChoiceProblemTypeBase, ProblemTypeTestMixin):
|
||||
class MultipleChoiceProblemTypeTest(MultipleChoiceProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the Multiple Choice Problem Type
|
||||
"""
|
||||
shard = 24
|
||||
|
||||
def test_can_show_answer(self):
|
||||
"""
|
||||
Scenario: Verifies that show answer button is working as expected.
|
||||
|
||||
Given that I am on courseware page
|
||||
And I can see a CAPA problem with show answer button
|
||||
When I click "Show Answer" button
|
||||
The correct answer is displayed with a single correctness indicator.
|
||||
"""
|
||||
# Click the correct answer, but don't submit yet. No correctness shows.
|
||||
self.answer_problem('correct')
|
||||
self.assertFalse(self.problem_page.is_correct_choice_highlighted(correct_choices=[3]))
|
||||
|
||||
# After submit, the answer should be marked as correct.
|
||||
self.problem_page.click_submit()
|
||||
self.assertTrue(self.problem_page.is_correct_choice_highlighted(correct_choices=[3], show_answer=False))
|
||||
|
||||
# Switch to an incorrect answer. This will hide the correctness indicator.
|
||||
self.answer_problem('incorrect')
|
||||
self.assertFalse(self.problem_page.is_correct_choice_highlighted(correct_choices=[3]))
|
||||
|
||||
# Now click Show Answer. A single correctness indicator should be shown.
|
||||
self.problem_page.click_show()
|
||||
self.assertTrue(self.problem_page.is_correct_choice_highlighted(correct_choices=[3]))
|
||||
|
||||
# Finally, make sure that clicking Show Answer moved focus to the correct place.
|
||||
self.problem_page.wait_for_show_answer_notification()
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class MultipleChoiceProblemResetCorrectnessAfterChangingAnswerTest(MultipleChoiceProblemTypeBase):
|
||||
"""
|
||||
Tests for Multiple choice problem with changing answers
|
||||
"""
|
||||
shard = 18
|
||||
|
||||
@ddt.data(['correct', '1/1 point (ungraded)'], ['incorrect', '0/1 point (ungraded)'])
|
||||
@ddt.unpack
|
||||
def test_mcq_score_after_answer_and_reset(self, correctness, score):
|
||||
"""
|
||||
Scenario: I can see my score on a multiple choice problem when I answer it and after I reset it
|
||||
|
||||
Given I am viewing a multiple choice problem
|
||||
When I answer a multiple choice problem <correctness>
|
||||
Then I should see a <score>
|
||||
When I reset the problem
|
||||
Then I should see a score of points possible: 0/1 point (ungraded)
|
||||
"""
|
||||
self.answer_problem(correctness)
|
||||
self.problem_page.click_submit()
|
||||
self.assertEqual(self.problem_page.problem_progress_graded_value, score)
|
||||
self.problem_page.click_reset()
|
||||
self.assertEqual(self.problem_page.problem_progress_graded_value, '0/1 point (ungraded)')
|
||||
|
||||
@ddt.data(['correct', 'incorrect'], ['incorrect', 'correct'])
|
||||
@ddt.unpack
|
||||
def test_reset_correctness_after_changing_answer(self, initial_correctness, other_correctness):
|
||||
"""
|
||||
Scenario: I can reset the correctness of a multiple choice problem after changing my answer
|
||||
|
||||
Given I am viewing a multiple choice problem
|
||||
When I answer a multiple choice problem <initial_correctness>
|
||||
Then my multiple choice answer is marked <initial_correctness>
|
||||
And I reset the problem
|
||||
Then my multiple choice answer is NOT marked <initial_correctness>
|
||||
And my multiple choice answer is NOT marked <other_correctness>
|
||||
"""
|
||||
self.assertTrue(self.problem_status("unanswered"))
|
||||
self.answer_problem(initial_correctness)
|
||||
self.problem_page.click_submit()
|
||||
|
||||
self.assertTrue(self.problem_status(initial_correctness))
|
||||
self.problem_page.click_reset()
|
||||
|
||||
self.assertFalse(self.problem_status(initial_correctness))
|
||||
self.assertFalse(self.problem_status(other_correctness))
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class MultipleChoiceProblemTypeTestNonRandomized(MultipleChoiceProblemTypeBase, NonRandomizedProblemTypeTestMixin):
|
||||
class MultipleChoiceProblemTypeTestNonRandomized(MultipleChoiceProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Tests for non-randomized multiple choice problem
|
||||
"""
|
||||
@@ -866,131 +387,6 @@ class MultipleChoiceProblemTypeTestNonRandomized(MultipleChoiceProblemTypeBase,
|
||||
metadata={'rerandomize': 'never', 'show_reset_button': True, 'max_attempts': 3}
|
||||
)
|
||||
|
||||
def test_non_randomized_multiple_choice_with_multiple_attempts(self):
|
||||
"""
|
||||
Scenario: I can answer a problem with multiple attempts correctly but cannot reset because randomization is off
|
||||
|
||||
Given I am viewing a randomization "never" "multiple choice" problem with "3" attempts with reset
|
||||
Then I should see "You have used 0 of 3 attempts" somewhere in the page
|
||||
When I answer a "multiple choice" problem "correctly"
|
||||
Then The "Reset" button does not appear
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.problem_page.submission_feedback,
|
||||
"You have used 0 of 3 attempts",
|
||||
"All 3 attempts are not available"
|
||||
)
|
||||
|
||||
self.answer_problem("correct")
|
||||
self.problem_page.click_submit()
|
||||
self.assertFalse(self.problem_page.is_reset_button_present())
|
||||
|
||||
|
||||
class MultipleChoiceProblemTypeTestOneAttempt(MultipleChoiceProblemTypeBase):
|
||||
"""
|
||||
Test Multiple choice problem with single attempt
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Creates a {problem_type} problem
|
||||
"""
|
||||
# Generate the problem XML using capa.tests.response_xml_factory
|
||||
return XBlockFixtureDesc(
|
||||
'problem',
|
||||
self.problem_name,
|
||||
data=self.factory.build_xml(**self.factory_kwargs),
|
||||
metadata={'rerandomize': 'never', 'show_reset_button': True, 'max_attempts': 1}
|
||||
)
|
||||
|
||||
def test_answer_with_one_attempt_correctly(self):
|
||||
"""
|
||||
Scenario: I can answer a problem with one attempt correctly and can not reset
|
||||
|
||||
Given I am viewing a "multiple choice" problem with "1" attempt
|
||||
When I answer a "multiple choice" problem "correctly"
|
||||
Then The "Reset" button does not appear
|
||||
"""
|
||||
self.answer_problem("correct")
|
||||
self.problem_page.click_submit()
|
||||
self.assertFalse(self.problem_page.is_reset_button_present())
|
||||
|
||||
|
||||
class MultipleChoiceProblemTypeTestMultipleAttempt(MultipleChoiceProblemTypeBase):
|
||||
"""
|
||||
Test Multiple choice problem with multiple attempts
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Creates a {problem_type} problem
|
||||
"""
|
||||
# Generate the problem XML using capa.tests.response_xml_factory
|
||||
return XBlockFixtureDesc(
|
||||
'problem',
|
||||
self.problem_name,
|
||||
data=self.factory.build_xml(**self.factory_kwargs),
|
||||
metadata={'rerandomize': 'always', 'show_reset_button': True, 'max_attempts': 3}
|
||||
)
|
||||
|
||||
def test_answer_with_multiple_attempt_correctly(self):
|
||||
"""
|
||||
Scenario: I can answer a problem with multiple attempts correctly and still reset the problem
|
||||
|
||||
Given I am viewing a "multiple choice" problem with "3" attempts
|
||||
Then I should see "You have used 0 of 3 attempts" somewhere in the page
|
||||
When I answer a "multiple choice" problem "correctly"
|
||||
Then The "Reset" button does appear
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.problem_page.submission_feedback,
|
||||
"You have used 0 of 3 attempts",
|
||||
"All 3 attempts are not available"
|
||||
)
|
||||
self.answer_problem("correct")
|
||||
self.problem_page.click_submit()
|
||||
self.assertTrue(self.problem_page.is_reset_button_present())
|
||||
|
||||
def test_learner_can_see_attempts_left(self):
|
||||
"""
|
||||
Scenario: I can view how many attempts I have left on a problem
|
||||
|
||||
Given I am viewing a "multiple choice" problem with "3" attempts
|
||||
Then I should see "You have used 0 of 3 attempts" somewhere in the page
|
||||
When I answer a "multiple choice" problem "incorrectly"
|
||||
And I reset the problem
|
||||
Then I should see "You have used 1 of 3 attempts" somewhere in the page
|
||||
When I answer a "multiple choice" problem "incorrectly"
|
||||
And I reset the problem
|
||||
Then I should see "You have used 2 of 3 attempts" somewhere in the page
|
||||
And The "Submit" button does appear
|
||||
When I answer a "multiple choice" problem "correctly"
|
||||
Then The "Reset" button does not appear
|
||||
"""
|
||||
for attempts_used in range(3):
|
||||
self.assertEqual(
|
||||
self.problem_page.submission_feedback,
|
||||
u"You have used {} of 3 attempts".format(str(attempts_used)),
|
||||
"All 3 attempts are not available"
|
||||
)
|
||||
if attempts_used == 2:
|
||||
self.assertTrue(self.problem_page.is_submit_disabled())
|
||||
self.answer_problem("correct")
|
||||
self.problem_page.click_submit()
|
||||
self.assertFalse(self.problem_page.is_reset_button_present())
|
||||
else:
|
||||
self.answer_problem("incorrect")
|
||||
self.problem_page.click_submit()
|
||||
self.problem_page.click_reset()
|
||||
|
||||
|
||||
class MultipleChoiceProblemTypeNeverShowCorrectnessTest(MultipleChoiceProblemTypeBase,
|
||||
ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Multiple Choice Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class RadioProblemTypeBase(ProblemTypeTestBase):
|
||||
"""
|
||||
@@ -1044,7 +440,7 @@ class RadioProblemTypeBase(ProblemTypeTestBase):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class RadioProblemTypeTest(RadioProblemTypeBase, ProblemTypeTestMixin):
|
||||
class RadioProblemTypeTest(RadioProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the Multiple Radio Problem Type
|
||||
"""
|
||||
@@ -1052,56 +448,7 @@ class RadioProblemTypeTest(RadioProblemTypeBase, ProblemTypeTestMixin):
|
||||
pass
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class RadioProblemResetCorrectnessAfterChangingAnswerTest(RadioProblemTypeBase):
|
||||
"""
|
||||
Tests for Radio problem with changing answers
|
||||
"""
|
||||
shard = 24
|
||||
|
||||
@ddt.data(['correct', '1/1 point (ungraded)'], ['incorrect', '0/1 point (ungraded)'])
|
||||
@ddt.unpack
|
||||
def test_radio_score_after_answer_and_reset(self, correctness, score):
|
||||
"""
|
||||
Scenario: I can see my score on a radio problem when I answer it and after I reset it
|
||||
|
||||
Given I am viewing a radio problem
|
||||
When I answer a radio problem <correctness>
|
||||
Then I should see a <score>
|
||||
When I reset the problem
|
||||
Then I should see a score of points possible: 0/1 point (ungraded)
|
||||
"""
|
||||
self.answer_problem(correctness)
|
||||
self.problem_page.click_submit()
|
||||
self.assertEqual(self.problem_page.problem_progress_graded_value, score)
|
||||
self.problem_page.click_reset()
|
||||
self.assertEqual(self.problem_page.problem_progress_graded_value, '0/1 point (ungraded)')
|
||||
|
||||
@ddt.data(['correct', 'incorrect'], ['incorrect', 'correct'])
|
||||
@ddt.unpack
|
||||
def test_reset_correctness_after_changing_answer(self, initial_correctness, other_correctness):
|
||||
"""
|
||||
Scenario: I can reset the correctness of a radio problem after changing my answer
|
||||
|
||||
Given I am viewing a radio problem
|
||||
When I answer a radio problem with <initial_correctness>
|
||||
Then my radio answer is marked <initial_correctness>
|
||||
And I reset the problem
|
||||
Then my radio problem's answer is NOT marked <initial_correctness>
|
||||
And my radio problem's answer is NOT marked <other_correctness>
|
||||
"""
|
||||
self.assertTrue(self.problem_status("unanswered"))
|
||||
self.answer_problem(initial_correctness)
|
||||
self.problem_page.click_submit()
|
||||
|
||||
self.assertTrue(self.problem_status(initial_correctness))
|
||||
self.problem_page.click_reset()
|
||||
|
||||
self.assertFalse(self.problem_status(initial_correctness))
|
||||
self.assertFalse(self.problem_status(other_correctness))
|
||||
|
||||
|
||||
class RadioProblemTypeTestNonRandomized(RadioProblemTypeBase, NonRandomizedProblemTypeTestMixin):
|
||||
class RadioProblemTypeTestNonRandomized(RadioProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Tests for non-randomized radio problem
|
||||
"""
|
||||
@@ -1120,13 +467,6 @@ class RadioProblemTypeTestNonRandomized(RadioProblemTypeBase, NonRandomizedProbl
|
||||
)
|
||||
|
||||
|
||||
class RadioProblemTypeNeverShowCorrectnessTest(RadioProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Radio Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class DropDownProblemTypeBase(ProblemTypeTestBase):
|
||||
"""
|
||||
ProblemTypeTestBase specialization for Drop Down Problem Type
|
||||
@@ -1155,7 +495,7 @@ class DropDownProblemTypeBase(ProblemTypeTestBase):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class DropdownProblemTypeTest(DropDownProblemTypeBase, ProblemTypeTestMixin, ChangingAnswerOfProblemTestMixin):
|
||||
class DropdownProblemTypeTest(DropDownProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the Dropdown Problem Type
|
||||
"""
|
||||
@@ -1164,7 +504,7 @@ class DropdownProblemTypeTest(DropDownProblemTypeBase, ProblemTypeTestMixin, Cha
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class DropDownProblemTypeTestNonRandomized(DropDownProblemTypeBase, NonRandomizedProblemTypeTestMixin):
|
||||
class DropDownProblemTypeTestNonRandomized(DropDownProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Tests for non-randomized Dropdown problem
|
||||
"""
|
||||
@@ -1183,13 +523,6 @@ class DropDownProblemTypeTestNonRandomized(DropDownProblemTypeBase, NonRandomize
|
||||
)
|
||||
|
||||
|
||||
class DropDownProblemTypeNeverShowCorrectnessTest(DropDownProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Drop Down Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class StringProblemTypeBase(ProblemTypeTestBase):
|
||||
"""
|
||||
ProblemTypeTestBase specialization for String Problem Type
|
||||
@@ -1239,7 +572,7 @@ class StringProblemTypeBase(ProblemTypeTestBase):
|
||||
self.problem_page.fill_answer(textvalue)
|
||||
|
||||
|
||||
class StringProblemTypeTest(StringProblemTypeBase, ProblemTypeTestMixin):
|
||||
class StringProblemTypeTest(StringProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the String Problem Type
|
||||
"""
|
||||
@@ -1247,13 +580,6 @@ class StringProblemTypeTest(StringProblemTypeBase, ProblemTypeTestMixin):
|
||||
pass
|
||||
|
||||
|
||||
class StringProblemTypeNeverShowCorrectnessTest(StringProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for String Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class NumericalProblemTypeBase(ProblemTypeTestBase):
|
||||
"""
|
||||
ProblemTypeTestBase specialization for Numerical Problem Type
|
||||
@@ -1311,39 +637,15 @@ class NumericalProblemTypeBase(ProblemTypeTestBase):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class NumericalProblemTypeTest(NumericalProblemTypeBase, ProblemTypeTestMixin, ChangingAnswerOfProblemTestMixin):
|
||||
class NumericalProblemTypeTest(NumericalProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the Numerical Problem Type
|
||||
"""
|
||||
shard = 12
|
||||
|
||||
def test_error_input_gentle_alert(self):
|
||||
"""
|
||||
Scenario: I can answer a problem with erroneous input and will see a gentle alert
|
||||
Given a Numerical Problem type
|
||||
I can input a string answer
|
||||
Then I will see a Gentle alert notification
|
||||
And focus will shift to that notification
|
||||
And clicking on "Review" moves focus to the problem meta area
|
||||
"""
|
||||
# Make sure we're looking at the right problem
|
||||
self.problem_page.wait_for(
|
||||
lambda: self.problem_page.problem_name == self.problem_name,
|
||||
"Make sure the correct problem is on the page"
|
||||
)
|
||||
|
||||
# Answer the problem with an erroneous input to cause a gentle alert
|
||||
self.assertFalse(self.problem_page.is_gentle_alert_notification_visible())
|
||||
self.answer_problem(correctness='error')
|
||||
self.problem_page.click_submit()
|
||||
self.problem_page.wait_for_gentle_alert_notification()
|
||||
# Check that clicking on "Review" goes to the problem meta location
|
||||
self.problem_page.click_review_in_notification(notification_type='gentle-alert')
|
||||
self.problem_page.wait_for_focus_on_problem_meta()
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class NumericalProblemTypeTestNonRandomized(NumericalProblemTypeBase, NonRandomizedProblemTypeTestMixin):
|
||||
class NumericalProblemTypeTestNonRandomized(NumericalProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Tests for non-randomized Numerical problem
|
||||
"""
|
||||
@@ -1362,42 +664,6 @@ class NumericalProblemTypeTestNonRandomized(NumericalProblemTypeBase, NonRandomi
|
||||
)
|
||||
|
||||
|
||||
class NumericalProblemTypeTestViewAnswer(NumericalProblemTypeBase):
|
||||
"""
|
||||
Test learner can view Numerical problem's answer
|
||||
"""
|
||||
|
||||
def get_problem(self):
|
||||
"""
|
||||
Creates a {problem_type} problem
|
||||
"""
|
||||
# Generate the problem XML using capa.tests.response_xml_factory
|
||||
return XBlockFixtureDesc(
|
||||
'problem',
|
||||
self.problem_name,
|
||||
data=self.factory.build_xml(**self.factory_kwargs),
|
||||
metadata={'showanswer': 'always'}
|
||||
)
|
||||
|
||||
def test_learner_can_view_answer(self):
|
||||
"""
|
||||
Scenario: I can view the answer if the problem has it:
|
||||
|
||||
Given I am viewing a "numerical" that shows the answer "always"
|
||||
When I press the button with the label "Show Answer"
|
||||
And I should see "4.14159" somewhere in the page
|
||||
"""
|
||||
self.problem_page.click_show()
|
||||
self.assertEqual(self.problem_page.answer, '4.14159')
|
||||
|
||||
|
||||
class NumericalProblemTypeNeverShowCorrectnessTest(NumericalProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Numerical Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class FormulaProblemTypeBase(ProblemTypeTestBase):
|
||||
"""
|
||||
@@ -1450,13 +716,6 @@ class FormulaProblemTypeBase(ProblemTypeTestBase):
|
||||
self.problem_page.fill_answer(textvalue)
|
||||
|
||||
|
||||
class FormulaProblemTypeNeverShowCorrectnessTest(FormulaProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Formula Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ScriptProblemTypeBase(ProblemTypeTestBase):
|
||||
"""
|
||||
@@ -1527,7 +786,7 @@ class ScriptProblemTypeBase(ProblemTypeTestBase):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ScriptProblemTypeTest(ScriptProblemTypeBase, ProblemTypeTestMixin):
|
||||
class ScriptProblemTypeTest(ScriptProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the Script Problem Type
|
||||
"""
|
||||
@@ -1535,36 +794,7 @@ class ScriptProblemTypeTest(ScriptProblemTypeBase, ProblemTypeTestMixin):
|
||||
pass
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ScriptProblemResetAfterAnswerTest(ScriptProblemTypeBase):
|
||||
"""
|
||||
Test Script problem by resetting answers
|
||||
"""
|
||||
shard = 8
|
||||
|
||||
@ddt.data(['correct', 'incorrect'], ['incorrect', 'correct'])
|
||||
@ddt.unpack
|
||||
def test_reset_correctness_after_changing_answer(self, initial_correctness, other_correctness):
|
||||
"""
|
||||
Scenario: I can reset the correctness of a problem after changing my answer
|
||||
|
||||
Given I am viewing a script problem
|
||||
Then my script problem's answer is marked "unanswered"
|
||||
When I answer a script problem initial correctness
|
||||
And I input an answer on a script problem other correctness
|
||||
Then my script problem answer is marked "unanswered"
|
||||
"""
|
||||
self.assertTrue(self.problem_status('unanswered'))
|
||||
self.answer_problem(initial_correctness)
|
||||
self.problem_page.click_submit()
|
||||
|
||||
self.assertTrue(self.problem_status(initial_correctness))
|
||||
|
||||
self.answer_problem(other_correctness)
|
||||
self.assertTrue(self.problem_status('unanswered'))
|
||||
|
||||
|
||||
class ScriptProblemTypeTestNonRandomized(ScriptProblemTypeBase, NonRandomizedProblemTypeTestMixin):
|
||||
class ScriptProblemTypeTestNonRandomized(ScriptProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Tests for non-randomized Script problem
|
||||
"""
|
||||
@@ -1583,13 +813,6 @@ class ScriptProblemTypeTestNonRandomized(ScriptProblemTypeBase, NonRandomizedPro
|
||||
)
|
||||
|
||||
|
||||
class ScriptProblemTypeNeverShowCorrectnessTest(ScriptProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Script Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class JSInputTypeTest(ProblemTypeTestBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
TestCase Class for jsinput (custom JavaScript) problem type.
|
||||
@@ -1651,33 +874,12 @@ class CodeProblemTypeBase(ProblemTypeTestBase):
|
||||
pass
|
||||
|
||||
|
||||
class CodeProblemTypeTest(CodeProblemTypeBase, ProblemTypeTestMixin):
|
||||
class CodeProblemTypeTest(CodeProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the Code Problem Type
|
||||
"""
|
||||
shard = 12
|
||||
|
||||
def test_answer_incorrectly(self):
|
||||
"""
|
||||
Overridden for script test because the testing grader always responds
|
||||
with "correct"
|
||||
"""
|
||||
pass
|
||||
|
||||
def test_submit_blank_answer(self):
|
||||
"""
|
||||
Overridden for script test because the testing grader always responds
|
||||
with "correct"
|
||||
"""
|
||||
pass
|
||||
|
||||
def test_cant_submit_blank_answer(self):
|
||||
"""
|
||||
Overridden for script test because the testing grader always responds
|
||||
with "correct"
|
||||
"""
|
||||
pass
|
||||
|
||||
def wait_for_status(self, status):
|
||||
"""
|
||||
Overridden for script test because the testing grader always responds
|
||||
@@ -1686,13 +888,6 @@ class CodeProblemTypeTest(CodeProblemTypeBase, ProblemTypeTestMixin):
|
||||
pass
|
||||
|
||||
|
||||
class CodeProblemTypeNeverShowCorrectnessTest(CodeProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Code Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ChoiceTextProblemTypeTestBase(ProblemTypeTestBase):
|
||||
"""
|
||||
Base class for "Choice + Text" Problem Types.
|
||||
@@ -1791,7 +986,7 @@ class RadioTextProblemTypeBase(ChoiceTextProblemTypeTestBase):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class RadioTextProblemTypeTest(RadioTextProblemTypeBase, ProblemTypeTestMixin):
|
||||
class RadioTextProblemTypeTest(RadioTextProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the Radio Text Problem Type
|
||||
"""
|
||||
@@ -1799,56 +994,7 @@ class RadioTextProblemTypeTest(RadioTextProblemTypeBase, ProblemTypeTestMixin):
|
||||
pass
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class RadioTextProblemResetCorrectnessAfterChangingAnswerTest(RadioTextProblemTypeBase):
|
||||
"""
|
||||
Tests for Radio Text problem with changing answers
|
||||
"""
|
||||
shard = 18
|
||||
|
||||
@ddt.data(['correct', '1/1 point (ungraded)'], ['incorrect', '0/1 point (ungraded)'])
|
||||
@ddt.unpack
|
||||
def test_mcq_score_after_answer_and_reset(self, correctness, score):
|
||||
"""
|
||||
Scenario: I can see my score on a radio text problem when I answer it and after I reset it
|
||||
|
||||
Given I am viewing a radio text problem
|
||||
When I answer a radio text problem correct/incorrect
|
||||
Then I should see a score
|
||||
When I reset the problem
|
||||
Then I should see a score of points possible: (1/1 point (ungraded) -- 0/1 point (ungraded)
|
||||
"""
|
||||
self.answer_problem(correctness)
|
||||
self.problem_page.click_submit()
|
||||
self.assertEqual(self.problem_page.problem_progress_graded_value, score)
|
||||
self.problem_page.click_reset()
|
||||
self.assertEqual(self.problem_page.problem_progress_graded_value, '0/1 point (ungraded)')
|
||||
|
||||
@ddt.data(['correct', 'incorrect'], ['incorrect', 'correct'])
|
||||
@ddt.unpack
|
||||
def test_reset_correctness_after_changing_answer(self, initial_correctness, other_correctness):
|
||||
"""
|
||||
Scenario: I can reset the correctness of a multiple choice problem after changing my answer
|
||||
|
||||
Given I am viewing a radio text problem
|
||||
When I answer a radio text problem InitialCorrectness
|
||||
Then my radio text answer is marked InitialCorrectness
|
||||
And I reset the problem
|
||||
Then my answer is NOT marked InitialCorrectness
|
||||
And my answer is NOT marked OtherCorrectness
|
||||
"""
|
||||
self.assertTrue(self.problem_status("unanswered"))
|
||||
self.answer_problem(initial_correctness)
|
||||
self.problem_page.click_submit()
|
||||
|
||||
self.assertTrue(self.problem_status(initial_correctness))
|
||||
self.problem_page.click_reset()
|
||||
|
||||
self.assertFalse(self.problem_status(initial_correctness))
|
||||
self.assertFalse(self.problem_status(other_correctness))
|
||||
|
||||
|
||||
class RadioTextProblemTypeTestNonRandomized(RadioTextProblemTypeBase, NonRandomizedProblemTypeTestMixin):
|
||||
class RadioTextProblemTypeTestNonRandomized(RadioTextProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Tests for non-randomized Radio text problem
|
||||
"""
|
||||
@@ -1867,13 +1013,6 @@ class RadioTextProblemTypeTestNonRandomized(RadioTextProblemTypeBase, NonRandomi
|
||||
)
|
||||
|
||||
|
||||
class RadioTextProblemTypeNeverShowCorrectnessTest(RadioTextProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Radio + Text Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CheckboxTextProblemTypeBase(ChoiceTextProblemTypeTestBase):
|
||||
"""
|
||||
ProblemTypeTestBase specialization for Checkbox Text Problem Type
|
||||
@@ -1909,14 +1048,14 @@ class CheckboxTextProblemTypeBase(ChoiceTextProblemTypeTestBase):
|
||||
})
|
||||
|
||||
|
||||
class CheckboxTextProblemTypeTest(CheckboxTextProblemTypeBase, ProblemTypeTestMixin):
|
||||
class CheckboxTextProblemTypeTest(CheckboxTextProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the Checkbox Text Problem Type
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CheckboxTextProblemTypeTestNonRandomized(CheckboxTextProblemTypeBase, NonRandomizedProblemTypeTestMixin):
|
||||
class CheckboxTextProblemTypeTestNonRandomized(CheckboxTextProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Tests for non-randomized Checkbox problem
|
||||
"""
|
||||
@@ -1934,13 +1073,6 @@ class CheckboxTextProblemTypeTestNonRandomized(CheckboxTextProblemTypeBase, NonR
|
||||
)
|
||||
|
||||
|
||||
class CheckboxTextProblemTypeNeverShowCorrectnessTest(CheckboxTextProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Checkbox + Text Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SymbolicProblemTypeBase(ProblemTypeTestBase):
|
||||
"""
|
||||
ProblemTypeTestBase specialization for Symbolic Problem Type
|
||||
@@ -1971,15 +1103,8 @@ class SymbolicProblemTypeBase(ProblemTypeTestBase):
|
||||
self.problem_page.fill_answer(choice)
|
||||
|
||||
|
||||
class SymbolicProblemTypeTest(SymbolicProblemTypeBase, ProblemTypeTestMixin):
|
||||
class SymbolicProblemTypeTest(SymbolicProblemTypeBase, ProblemTypeA11yTestMixin):
|
||||
"""
|
||||
Standard tests for the Symbolic Problem Type
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SymbolicProblemTypeNeverShowCorrectnessTest(SymbolicProblemTypeBase, ProblemNeverShowCorrectnessMixin):
|
||||
"""
|
||||
Ensure that correctness can be withheld for Symbolic Problem Type problems.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -74,30 +74,6 @@ class ProgramPageBase(ProgramsConfigMixin, CatalogIntegrationMixin, UniqueCourse
|
||||
cache_programs_page.visit()
|
||||
|
||||
|
||||
class ProgramListingPageTest(ProgramPageBase):
|
||||
"""Verify user-facing behavior of the program listing page."""
|
||||
shard = 21
|
||||
|
||||
def setUp(self):
|
||||
super(ProgramListingPageTest, self).setUp()
|
||||
|
||||
self.listing_page = ProgramListingPage(self.browser)
|
||||
|
||||
def test_no_programs(self):
|
||||
"""
|
||||
Verify that no cards appear when the user has enrollments
|
||||
but none are included in an active program.
|
||||
"""
|
||||
self.auth()
|
||||
self.stub_catalog_api(self.programs, self.pathways)
|
||||
self.cache_programs()
|
||||
|
||||
self.listing_page.visit()
|
||||
|
||||
self.assertTrue(self.listing_page.is_sidebar_present)
|
||||
self.assertFalse(self.listing_page.are_cards_present)
|
||||
|
||||
|
||||
class ProgramListingPageA11yTest(ProgramPageBase):
|
||||
"""Test program listing page accessibility."""
|
||||
a11y = True
|
||||
|
||||
@@ -7,24 +7,18 @@ progress page.
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
import ddt
|
||||
from six.moves import range
|
||||
|
||||
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from ...pages.common.logout import LogoutPage
|
||||
from ...pages.lms.courseware import CoursewarePage
|
||||
from ...pages.lms.instructor_dashboard import InstructorDashboardPage, StudentSpecificAdmin
|
||||
from ...pages.lms.problem import ProblemPage
|
||||
from ...pages.lms.progress import ProgressPage
|
||||
from ...pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
|
||||
from ...pages.studio.utils import type_in_codemirror
|
||||
from ...pages.studio.xblock_editor import XBlockEditorView
|
||||
from ..helpers import (
|
||||
UniqueCourseTest,
|
||||
auto_auth,
|
||||
create_multiple_choice_problem,
|
||||
create_multiple_choice_xml,
|
||||
get_modal_alert
|
||||
)
|
||||
|
||||
|
||||
@@ -128,154 +122,6 @@ class ProgressPageBaseTest(UniqueCourseTest):
|
||||
self.logout_page.visit()
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class PersistentGradesTest(ProgressPageBaseTest):
|
||||
"""
|
||||
Test that grades for completed assessments are persisted
|
||||
when various edits are made.
|
||||
"""
|
||||
shard = 22
|
||||
|
||||
def setUp(self):
|
||||
super(PersistentGradesTest, self).setUp()
|
||||
self.instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id)
|
||||
|
||||
def _change_subsection_structure(self):
|
||||
"""
|
||||
Adds a unit to the subsection, which
|
||||
should not affect a persisted subsection grade.
|
||||
"""
|
||||
self.studio_course_outline.visit()
|
||||
subsection = self.studio_course_outline.section(self.SECTION_NAME).subsection(self.SUBSECTION_NAME)
|
||||
subsection.expand_subsection()
|
||||
subsection.add_unit()
|
||||
self.studio_course_outline.wait_for_ajax()
|
||||
subsection.publish()
|
||||
|
||||
def _set_staff_lock_on_subsection(self, locked):
|
||||
"""
|
||||
Sets staff lock for a subsection, which should hide the
|
||||
subsection score from students on the progress page.
|
||||
"""
|
||||
self.studio_course_outline.visit()
|
||||
subsection = self.studio_course_outline.section_at(0).subsection_at(0)
|
||||
subsection.set_staff_lock(locked)
|
||||
self.assertEqual(subsection.has_staff_lock_warning, locked)
|
||||
|
||||
def _get_problem_in_studio(self):
|
||||
"""
|
||||
Returns the editable problem component in studio,
|
||||
along with its container unit, so any changes can
|
||||
be published.
|
||||
"""
|
||||
self.studio_course_outline.visit()
|
||||
self.studio_course_outline.section_at(0).subsection_at(0).expand_subsection()
|
||||
unit = self.studio_course_outline.section_at(0).subsection_at(0).unit(self.UNIT_NAME).go_to()
|
||||
component = unit.xblocks[1]
|
||||
return unit, component
|
||||
|
||||
def _change_weight_for_problem(self):
|
||||
"""
|
||||
Changes the weight of the problem, which should not affect
|
||||
persisted grades.
|
||||
"""
|
||||
unit, component = self._get_problem_in_studio()
|
||||
component.edit()
|
||||
component_editor = XBlockEditorView(self.browser, component.locator)
|
||||
component_editor.set_field_value_and_save('Problem Weight', 5)
|
||||
unit.publish()
|
||||
|
||||
def _change_correct_answer_for_problem(self, new_correct_choice=1):
|
||||
"""
|
||||
Changes the correct answer of the problem.
|
||||
"""
|
||||
unit, component = self._get_problem_in_studio()
|
||||
modal = component.edit()
|
||||
|
||||
modified_content = create_multiple_choice_xml(correct_choice=new_correct_choice)
|
||||
|
||||
type_in_codemirror(self, 0, modified_content)
|
||||
modal.q(css='.action-save').click()
|
||||
unit.publish()
|
||||
|
||||
def _student_admin_action_for_problem(self, action_button, has_cancellable_alert=False):
|
||||
"""
|
||||
As staff, clicks the "delete student state" button,
|
||||
deleting the student user's state for the problem.
|
||||
"""
|
||||
self.instructor_dashboard_page.visit()
|
||||
student_admin_section = self.instructor_dashboard_page.select_student_admin(StudentSpecificAdmin)
|
||||
student_admin_section.set_student_email_or_username(self.USERNAME)
|
||||
student_admin_section.set_problem_location(self.problem1.locator)
|
||||
getattr(student_admin_section, action_button).click()
|
||||
if has_cancellable_alert:
|
||||
alert = get_modal_alert(student_admin_section.browser)
|
||||
alert.accept()
|
||||
alert = get_modal_alert(student_admin_section.browser)
|
||||
alert.dismiss()
|
||||
return student_admin_section
|
||||
|
||||
def test_progress_page_shows_scored_problems(self):
|
||||
"""
|
||||
Checks the progress page before and after answering
|
||||
the course's first problem correctly.
|
||||
"""
|
||||
with self._logged_in_session():
|
||||
self.assertEqual(self._get_problem_scores(), [(0, 1), (0, 1)])
|
||||
self.assertEqual(self._get_section_score(), (0, 2))
|
||||
self.courseware_page.visit()
|
||||
self._answer_problem_correctly()
|
||||
self.assertEqual(self._get_problem_scores(), [(1, 1), (0, 1)])
|
||||
self.assertEqual(self._get_section_score(), (1, 2))
|
||||
|
||||
@ddt.data(
|
||||
_change_subsection_structure,
|
||||
_change_weight_for_problem
|
||||
)
|
||||
def test_content_changes_do_not_change_score(self, edit):
|
||||
with self._logged_in_session():
|
||||
self.courseware_page.visit()
|
||||
self._answer_problem_correctly()
|
||||
|
||||
with self._logged_in_session(staff=True):
|
||||
edit(self)
|
||||
|
||||
with self._logged_in_session():
|
||||
self.assertEqual(self._get_problem_scores(), [(1, 1), (0, 1)])
|
||||
self.assertEqual(self._get_section_score(), (1, 2))
|
||||
|
||||
def test_visibility_change_affects_score(self):
|
||||
with self._logged_in_session():
|
||||
self.courseware_page.visit()
|
||||
self._answer_problem_correctly()
|
||||
|
||||
with self._logged_in_session(staff=True):
|
||||
self._set_staff_lock_on_subsection(True)
|
||||
|
||||
with self._logged_in_session():
|
||||
self.assertEqual(self._get_problem_scores(), None)
|
||||
self.assertEqual(self._get_section_score(), None)
|
||||
|
||||
with self._logged_in_session(staff=True):
|
||||
self._set_staff_lock_on_subsection(False)
|
||||
|
||||
with self._logged_in_session():
|
||||
self.assertEqual(self._get_problem_scores(), [(1, 1), (0, 1)])
|
||||
self.assertEqual(self._get_section_score(), (1, 2))
|
||||
|
||||
def test_delete_student_state_affects_score(self):
|
||||
with self._logged_in_session():
|
||||
self.courseware_page.visit()
|
||||
self._answer_problem_correctly()
|
||||
|
||||
with self._logged_in_session(staff=True):
|
||||
self._student_admin_action_for_problem('delete_state_button', has_cancellable_alert=True)
|
||||
|
||||
with self._logged_in_session():
|
||||
self.assertEqual(self._get_problem_scores(), [(0, 1), (0, 1)])
|
||||
self.assertEqual(self._get_section_score(), (0, 2))
|
||||
|
||||
|
||||
class SubsectionGradingPolicyBase(ProgressPageBaseTest):
|
||||
"""
|
||||
Base class for testing a subsection and its impact to
|
||||
|
||||
@@ -1,1987 +0,0 @@
|
||||
"""
|
||||
Acceptance tests for the teams feature.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import ddt
|
||||
from dateutil.parser import parse
|
||||
from selenium.common.exceptions import TimeoutException
|
||||
from six.moves import map, range
|
||||
|
||||
from common.test.acceptance.fixtures import LMS_BASE_URL
|
||||
from common.test.acceptance.fixtures.course import CourseFixture
|
||||
from common.test.acceptance.fixtures.discussion import ForumsConfigMixin, MultipleThreadFixture, Thread
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.common.utils import confirm_prompt
|
||||
from common.test.acceptance.pages.lms.course_home import CourseHomePage
|
||||
from common.test.acceptance.pages.lms.learner_profile import LearnerProfilePage
|
||||
from common.test.acceptance.pages.lms.tab_nav import TabNavPage
|
||||
from common.test.acceptance.pages.lms.teams import (
|
||||
BrowseTeamsPage,
|
||||
BrowseTopicsPage,
|
||||
EditMembershipPage,
|
||||
MyTeamsPage,
|
||||
TeamManagementPage,
|
||||
TeamPage,
|
||||
TeamsPage
|
||||
)
|
||||
from common.test.acceptance.tests.helpers import EventsTestMixin, UniqueCourseTest, get_modal_alert
|
||||
from openedx.core.lib.tests import attr
|
||||
|
||||
TOPICS_PER_PAGE = 12
|
||||
|
||||
|
||||
class TeamsTabBase(EventsTestMixin, ForumsConfigMixin, UniqueCourseTest):
|
||||
"""Base class for Teams Tab tests"""
|
||||
def setUp(self):
|
||||
super(TeamsTabBase, self).setUp()
|
||||
self.tab_nav = TabNavPage(self.browser)
|
||||
self.course_home_page = CourseHomePage(self.browser, self.course_id)
|
||||
self.teams_page = TeamsPage(self.browser, self.course_id)
|
||||
# TODO: Refactor so resetting events database is not necessary
|
||||
self.reset_event_tracking()
|
||||
|
||||
self.enable_forums()
|
||||
|
||||
def create_topics(self, num_topics):
|
||||
"""Create `num_topics` test topics."""
|
||||
return [{u"description": i, u"name": i, u"id": i} for i in map(str, range(num_topics))]
|
||||
|
||||
def create_teams(self, topic, num_teams, time_between_creation=0):
|
||||
"""Create `num_teams` teams belonging to `topic`."""
|
||||
teams = []
|
||||
for i in range(num_teams):
|
||||
team = {
|
||||
'course_id': self.course_id,
|
||||
'topic_id': topic['id'],
|
||||
'name': u'Team {}'.format(i),
|
||||
'description': u'Description {}'.format(i),
|
||||
'language': 'aa',
|
||||
'country': 'AF'
|
||||
}
|
||||
teams.append(self.post_team_data(team))
|
||||
# Sadly, this sleep is necessary in order to ensure that
|
||||
# sorting by last_activity_at works correctly when running
|
||||
# in Jenkins.
|
||||
# THIS IS AN ANTI-PATTERN - DO NOT COPY.
|
||||
time.sleep(time_between_creation)
|
||||
return teams
|
||||
|
||||
def post_team_data(self, team_data):
|
||||
"""Given a JSON representation of a team, post it to the server."""
|
||||
response = self.course_fixture.session.post(
|
||||
LMS_BASE_URL + '/api/team/v0/teams/',
|
||||
data=json.dumps(team_data),
|
||||
headers=self.course_fixture.headers
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return json.loads(response.text)
|
||||
|
||||
def create_memberships(self, num_memberships, team_id):
|
||||
"""Create `num_memberships` users and assign them to `team_id`. The
|
||||
last user created becomes the current user."""
|
||||
memberships = []
|
||||
for __ in range(num_memberships):
|
||||
user_info = AutoAuthPage(self.browser, course_id=self.course_id).visit().user_info
|
||||
memberships.append(user_info)
|
||||
self.create_membership(user_info['username'], team_id)
|
||||
#pylint: disable=attribute-defined-outside-init
|
||||
self.user_info = memberships[-1]
|
||||
return memberships
|
||||
|
||||
def create_membership(self, username, team_id):
|
||||
"""Assign `username` to `team_id`."""
|
||||
response = self.course_fixture.session.post(
|
||||
LMS_BASE_URL + '/api/team/v0/team_membership/',
|
||||
data=json.dumps({'username': username, 'team_id': team_id}),
|
||||
headers=self.course_fixture.headers
|
||||
)
|
||||
return json.loads(response.text)
|
||||
|
||||
def set_team_configuration(self, configuration_data, enroll_in_course=True, global_staff=False):
|
||||
"""
|
||||
Sets team configuration on the course and calls auto-auth on the user.
|
||||
"""
|
||||
#pylint: disable=attribute-defined-outside-init
|
||||
self.course_fixture = CourseFixture(**self.course_info)
|
||||
if configuration_data:
|
||||
self.course_fixture.add_advanced_settings(
|
||||
{u"teams_configuration": {u"value": configuration_data}}
|
||||
)
|
||||
self.course_fixture.install()
|
||||
|
||||
enroll_course_id = self.course_id if enroll_in_course else None
|
||||
#pylint: disable=attribute-defined-outside-init
|
||||
self.user_info = AutoAuthPage(self.browser, course_id=enroll_course_id, staff=global_staff).visit().user_info
|
||||
self.course_home_page.visit()
|
||||
|
||||
def verify_teams_present(self, present):
|
||||
"""
|
||||
Verifies whether or not the teams tab is present. If it should be present, also
|
||||
checks the text on the page (to ensure view is working).
|
||||
"""
|
||||
if present:
|
||||
self.assertIn("Teams", self.tab_nav.tab_names)
|
||||
self.teams_page.visit()
|
||||
self.assertEqual(self.teams_page.active_tab(), 'browse')
|
||||
else:
|
||||
self.assertNotIn("Teams", self.tab_nav.tab_names)
|
||||
|
||||
def verify_teams(self, page, expected_teams):
|
||||
"""Verify that the list of team cards on the current page match the expected teams in order."""
|
||||
|
||||
def assert_team_equal(expected_team, team_card_name, team_card_description):
|
||||
"""
|
||||
Helper to assert that a single team card has the expected name and
|
||||
description.
|
||||
"""
|
||||
self.assertEqual(expected_team['name'], team_card_name)
|
||||
self.assertEqual(expected_team['description'], team_card_description)
|
||||
|
||||
team_card_names = page.team_names
|
||||
team_card_descriptions = page.team_descriptions
|
||||
list(map(assert_team_equal, expected_teams, team_card_names, team_card_descriptions))
|
||||
|
||||
def verify_my_team_count(self, expected_number_of_teams):
|
||||
""" Verify the number of teams shown on "My Team". """
|
||||
|
||||
# We are doing these operations on this top-level page object to avoid reloading the page.
|
||||
self.teams_page.verify_my_team_count(expected_number_of_teams)
|
||||
|
||||
def only_team_events(self, event):
|
||||
"""Filter out all non-team events."""
|
||||
return event['event_type'].startswith('edx.team.')
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@attr(shard=5)
|
||||
class TeamsTabTest(TeamsTabBase):
|
||||
"""
|
||||
Tests verifying when the Teams tab is present.
|
||||
"""
|
||||
def test_teams_not_enabled(self):
|
||||
"""
|
||||
Scenario: teams tab should not be present if no team configuration is set
|
||||
Given I am enrolled in a course without team configuration
|
||||
When I view the course info page
|
||||
Then I should not see the Teams tab
|
||||
"""
|
||||
self.set_team_configuration(None)
|
||||
self.verify_teams_present(False)
|
||||
|
||||
def test_teams_not_enabled_no_topics(self):
|
||||
"""
|
||||
Scenario: teams tab should not be present if team configuration does not specify topics
|
||||
Given I am enrolled in a course with no topics in the team configuration
|
||||
When I view the course info page
|
||||
Then I should not see the Teams tab
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": []})
|
||||
self.verify_teams_present(False)
|
||||
|
||||
def test_teams_enabled(self):
|
||||
"""
|
||||
Scenario: teams tab should be present if user is enrolled in the course and it has team configuration
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I view the course info page
|
||||
Then I should see the Teams tab
|
||||
And the correct content should be on the page
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(1)})
|
||||
self.verify_teams_present(True)
|
||||
|
||||
def test_teams_enabled_global_staff(self):
|
||||
"""
|
||||
Scenario: teams tab should be present if user is not enrolled in the course, but is global staff
|
||||
Given there is a course with team configuration
|
||||
And I am not enrolled in that course, but am global staff
|
||||
When I view the course info page
|
||||
Then I should see the Teams tab
|
||||
And the correct content should be on the page
|
||||
"""
|
||||
self.set_team_configuration(
|
||||
{u"max_team_size": 10, u"topics": self.create_topics(1)},
|
||||
enroll_in_course=False,
|
||||
global_staff=True
|
||||
)
|
||||
self.verify_teams_present(True)
|
||||
|
||||
@ddt.data(
|
||||
'topics/{topic_id}',
|
||||
'topics/{topic_id}/search',
|
||||
'teams/{topic_id}/{team_id}/edit-team',
|
||||
'teams/{topic_id}/{team_id}'
|
||||
)
|
||||
def test_unauthorized_error_message(self, route):
|
||||
"""Ensure that an error message is shown to the user if they attempt
|
||||
to take an action which makes an AJAX request while not signed
|
||||
in.
|
||||
"""
|
||||
topics = self.create_topics(1)
|
||||
topic = topics[0]
|
||||
self.set_team_configuration(
|
||||
{u'max_team_size': 10, u'topics': topics},
|
||||
global_staff=True
|
||||
)
|
||||
team = self.create_teams(topic, 1)[0]
|
||||
self.teams_page.visit()
|
||||
self.browser.delete_cookie('sessionid')
|
||||
url = self.browser.current_url.split('#')[0]
|
||||
self.browser.get(
|
||||
'{url}#{route}'.format(
|
||||
url=url,
|
||||
route=route.format(
|
||||
topic_id=topic['id'],
|
||||
team_id=team['id']
|
||||
)
|
||||
)
|
||||
)
|
||||
self.teams_page.wait_for_ajax()
|
||||
self.assertEqual(
|
||||
self.teams_page.warning_message,
|
||||
u"Your request could not be completed. Reload the page and try again."
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
('browse', '.topics-list'),
|
||||
# TODO: find a reliable way to match the "My Teams" tab
|
||||
# ('my-teams', 'div.teams-list'),
|
||||
('teams/{topic_id}/{team_id}', 'div.discussion-module'),
|
||||
('topics/{topic_id}/create-team', 'div.create-team-instructions'),
|
||||
('topics/{topic_id}', '.teams-list'),
|
||||
('not-a-real-route', 'div.warning')
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_url_routing(self, route, selector):
|
||||
"""Ensure that navigating to a URL route correctly updates the page
|
||||
content.
|
||||
"""
|
||||
topics = self.create_topics(1)
|
||||
topic = topics[0]
|
||||
self.set_team_configuration({
|
||||
u'max_team_size': 10,
|
||||
u'topics': topics
|
||||
})
|
||||
team = self.create_teams(topic, 1)[0]
|
||||
self.teams_page.visit()
|
||||
|
||||
# Get the base URL (the URL without any trailing fragment)
|
||||
url = self.browser.current_url
|
||||
fragment_index = url.find('#')
|
||||
if fragment_index >= 0:
|
||||
url = url[0:fragment_index]
|
||||
|
||||
self.browser.get(
|
||||
'{url}#{route}'.format(
|
||||
url=url,
|
||||
route=route.format(
|
||||
topic_id=topic['id'],
|
||||
team_id=team['id']
|
||||
))
|
||||
)
|
||||
self.teams_page.wait_for_page()
|
||||
self.teams_page.wait_for_ajax()
|
||||
self.assertTrue(self.teams_page.q(css=selector).present)
|
||||
self.assertTrue(self.teams_page.q(css=selector).visible)
|
||||
|
||||
|
||||
@attr(shard=5)
|
||||
class MyTeamsTest(TeamsTabBase):
|
||||
"""
|
||||
Tests for the "My Teams" tab of the Teams page.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(MyTeamsTest, self).setUp()
|
||||
self.topic = {u"name": u"Example Topic", u"id": "example_topic", u"description": "Description"}
|
||||
self.set_team_configuration({'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]})
|
||||
self.my_teams_page = MyTeamsPage(self.browser, self.course_id)
|
||||
self.page_viewed_event = {
|
||||
'event_type': 'edx.team.page_viewed',
|
||||
'event': {
|
||||
'page_name': 'my-teams',
|
||||
'topic_id': None,
|
||||
'team_id': None
|
||||
}
|
||||
}
|
||||
|
||||
def test_not_member_of_any_teams(self):
|
||||
"""
|
||||
Scenario: Visiting the My Teams page when user is not a member of any team should not display any teams.
|
||||
Given I am enrolled in a course with a team configuration and a topic but am not a member of a team
|
||||
When I visit the My Teams page
|
||||
And I should see no teams
|
||||
And I should see a message that I belong to no teams.
|
||||
"""
|
||||
with self.assert_events_match_during(self.only_team_events, expected_events=[self.page_viewed_event]):
|
||||
self.my_teams_page.visit()
|
||||
self.assertEqual(len(self.my_teams_page.team_cards), 0, msg='Expected to see no team cards')
|
||||
self.assertEqual(
|
||||
self.my_teams_page.q(css='.page-content-main').text,
|
||||
[u'You are not currently a member of any team.']
|
||||
)
|
||||
|
||||
def test_member_of_a_team(self):
|
||||
"""
|
||||
Scenario: Visiting the My Teams page when user is a member of a team should display the teams.
|
||||
Given I am enrolled in a course with a team configuration and a topic and am a member of a team
|
||||
When I visit the My Teams page
|
||||
Then I should see a pagination header showing the number of teams
|
||||
And I should see all the expected team cards
|
||||
And I should not see a pagination footer
|
||||
"""
|
||||
teams = self.create_teams(self.topic, 1)
|
||||
self.create_membership(self.user_info['username'], teams[0]['id'])
|
||||
with self.assert_events_match_during(self.only_team_events, expected_events=[self.page_viewed_event]):
|
||||
self.my_teams_page.visit()
|
||||
self.verify_teams(self.my_teams_page, teams)
|
||||
|
||||
def test_multiple_team_members(self):
|
||||
"""
|
||||
Scenario: Visiting the My Teams page when user is a member of a team should display the teams.
|
||||
Given I am a member of a team with multiple members
|
||||
When I visit the My Teams page
|
||||
Then I should see the correct number of team members on my membership
|
||||
"""
|
||||
teams = self.create_teams(self.topic, 1)
|
||||
self.create_memberships(4, teams[0]['id'])
|
||||
self.my_teams_page.visit()
|
||||
self.assertEqual(self.my_teams_page.team_memberships[0], '4 / 10 Members')
|
||||
|
||||
|
||||
@attr(shard=5)
|
||||
@ddt.ddt
|
||||
class BrowseTopicsTest(TeamsTabBase):
|
||||
"""
|
||||
Tests for the Browse tab of the Teams page.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(BrowseTopicsTest, self).setUp()
|
||||
self.topics_page = BrowseTopicsPage(self.browser, self.course_id)
|
||||
|
||||
@ddt.data(('name', False), ('team_count', True))
|
||||
@ddt.unpack
|
||||
def test_sort_topics(self, sort_order, reverse):
|
||||
"""
|
||||
Scenario: the user should be able to sort the list of topics by name or team count
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
Then I should see a list of topics for the course
|
||||
When I choose a sort order
|
||||
Then I should see the paginated list of topics in that order
|
||||
"""
|
||||
topics = self.create_topics(TOPICS_PER_PAGE + 1)
|
||||
self.set_team_configuration({u"max_team_size": 100, u"topics": topics})
|
||||
for i, topic in enumerate(random.sample(topics, len(topics))):
|
||||
self.create_teams(topic, i)
|
||||
topic['team_count'] = i
|
||||
self.topics_page.visit()
|
||||
self.topics_page.sort_topics_by(sort_order)
|
||||
topic_names = self.topics_page.topic_names
|
||||
self.assertEqual(len(topic_names), TOPICS_PER_PAGE)
|
||||
self.assertEqual(
|
||||
topic_names,
|
||||
[t['name'] for t in sorted(topics, key=lambda t: t[sort_order], reverse=reverse)][:TOPICS_PER_PAGE]
|
||||
)
|
||||
|
||||
def test_sort_topics_update(self):
|
||||
"""
|
||||
Scenario: the list of topics should remain sorted after updates
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics and choose a sort order
|
||||
Then I should see the paginated list of topics in that order
|
||||
When I create a team in one of those topics
|
||||
And I return to the topics list
|
||||
Then I should see the topics in the correct sorted order
|
||||
"""
|
||||
topics = self.create_topics(3)
|
||||
self.set_team_configuration({u"max_team_size": 100, u"topics": topics})
|
||||
self.topics_page.visit()
|
||||
self.topics_page.sort_topics_by('team_count')
|
||||
topic_name = self.topics_page.topic_names[-1]
|
||||
topic = [t for t in topics if t['name'] == topic_name][0]
|
||||
self.topics_page.browse_teams_for_topic(topic_name)
|
||||
browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, topic)
|
||||
browse_teams_page.wait_for_page()
|
||||
browse_teams_page.click_create_team_link()
|
||||
create_team_page = TeamManagementPage(self.browser, self.course_id, topic)
|
||||
create_team_page.create_team()
|
||||
|
||||
team_page = TeamPage(self.browser, self.course_id)
|
||||
team_page.wait_for_page()
|
||||
|
||||
team_page.click_all_topics()
|
||||
self.topics_page.wait_for_page()
|
||||
self.topics_page.wait_for_ajax()
|
||||
self.assertEqual(topic_name, self.topics_page.topic_names[0])
|
||||
|
||||
def test_list_topics(self):
|
||||
"""
|
||||
Scenario: a list of topics should be visible in the "Browse" tab
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
Then I should see a list of topics for the course
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(2)})
|
||||
self.topics_page.visit()
|
||||
self.assertEqual(len(self.topics_page.topic_cards), 2)
|
||||
self.assertTrue(self.topics_page.get_pagination_header_text().startswith('Showing 1-2 out of 2 total'))
|
||||
self.assertFalse(self.topics_page.pagination_controls_visible())
|
||||
self.assertFalse(self.topics_page.is_previous_page_button_enabled())
|
||||
self.assertFalse(self.topics_page.is_next_page_button_enabled())
|
||||
|
||||
def test_topic_pagination(self):
|
||||
"""
|
||||
Scenario: a list of topics should be visible in the "Browse" tab, paginated 12 per page
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
Then I should see only the first 12 topics
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(20)})
|
||||
self.topics_page.visit()
|
||||
self.assertEqual(len(self.topics_page.topic_cards), TOPICS_PER_PAGE)
|
||||
self.assertTrue(self.topics_page.get_pagination_header_text().startswith('Showing 1-12 out of 20 total'))
|
||||
self.assertTrue(self.topics_page.pagination_controls_visible())
|
||||
self.assertFalse(self.topics_page.is_previous_page_button_enabled())
|
||||
self.assertTrue(self.topics_page.is_next_page_button_enabled())
|
||||
|
||||
def test_go_to_numbered_page(self):
|
||||
"""
|
||||
Scenario: topics should be able to be navigated by page number
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
And I enter a valid page number in the page number input
|
||||
Then I should see that page of topics
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(25)})
|
||||
self.topics_page.visit()
|
||||
self.topics_page.go_to_page(3)
|
||||
self.assertEqual(len(self.topics_page.topic_cards), 1)
|
||||
self.assertTrue(self.topics_page.is_previous_page_button_enabled())
|
||||
self.assertFalse(self.topics_page.is_next_page_button_enabled())
|
||||
|
||||
def test_go_to_invalid_page(self):
|
||||
"""
|
||||
Scenario: browsing topics should not respond to invalid page numbers
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
And I enter an invalid page number in the page number input
|
||||
Then I should stay on the current page
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(13)})
|
||||
self.topics_page.visit()
|
||||
self.topics_page.go_to_page(3)
|
||||
self.assertEqual(self.topics_page.get_current_page_number(), 1)
|
||||
|
||||
def test_page_navigation_buttons(self):
|
||||
"""
|
||||
Scenario: browsing topics should not respond to invalid page numbers
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
When I press the next page button
|
||||
Then I should move to the next page
|
||||
When I press the previous page button
|
||||
Then I should move to the previous page
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(13)})
|
||||
self.topics_page.visit()
|
||||
self.topics_page.press_next_page_button()
|
||||
self.assertEqual(len(self.topics_page.topic_cards), 1)
|
||||
self.assertTrue(self.topics_page.get_pagination_header_text().startswith('Showing 13-13 out of 13 total'))
|
||||
self.topics_page.press_previous_page_button()
|
||||
self.assertEqual(len(self.topics_page.topic_cards), TOPICS_PER_PAGE)
|
||||
self.assertTrue(self.topics_page.get_pagination_header_text().startswith('Showing 1-12 out of 13 total'))
|
||||
|
||||
def test_topic_pagination_one_page(self):
|
||||
"""
|
||||
Scenario: Browsing topics when there are fewer topics than the page size i.e. 12
|
||||
all topics should show on one page
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
And I should see corrected number of topic cards
|
||||
And I should see the correct page header
|
||||
And I should not see a pagination footer
|
||||
"""
|
||||
self.set_team_configuration({u"max_team_size": 10, u"topics": self.create_topics(10)})
|
||||
self.topics_page.visit()
|
||||
self.assertEqual(len(self.topics_page.topic_cards), 10)
|
||||
self.assertTrue(self.topics_page.get_pagination_header_text().startswith('Showing 1-10 out of 10 total'))
|
||||
self.assertFalse(self.topics_page.pagination_controls_visible())
|
||||
|
||||
def test_topic_description_truncation(self):
|
||||
"""
|
||||
Scenario: excessively long topic descriptions should be truncated so
|
||||
as to fit within a topic card.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
with a long description
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
Then I should see a truncated topic description
|
||||
"""
|
||||
initial_description = "A" + " really" * 50 + " long description"
|
||||
self.set_team_configuration(
|
||||
{
|
||||
u"max_team_size": 1,
|
||||
u"topics": [
|
||||
{"id": "long-description-topic", "description": initial_description},
|
||||
]
|
||||
}
|
||||
)
|
||||
self.topics_page.visit()
|
||||
truncated_description = self.topics_page.topic_descriptions[0]
|
||||
self.assertLess(len(truncated_description), len(initial_description))
|
||||
self.assertTrue(truncated_description.endswith('...'))
|
||||
self.assertIn(truncated_description.split('...')[0], initial_description)
|
||||
|
||||
def test_go_to_teams_list(self):
|
||||
"""
|
||||
Scenario: Clicking on a Topic Card should take you to the
|
||||
teams list for that Topic.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the Teams page
|
||||
And I browse topics
|
||||
And I click on the arrow link to view teams for the first topic
|
||||
Then I should be on the browse teams page
|
||||
"""
|
||||
topic = {u"name": u"Example Topic", u"id": u"example_topic", u"description": "Description"}
|
||||
self.set_team_configuration(
|
||||
{u"max_team_size": 1, u"topics": [topic]}
|
||||
)
|
||||
self.topics_page.visit()
|
||||
self.topics_page.browse_teams_for_topic('Example Topic')
|
||||
browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, topic)
|
||||
browse_teams_page.wait_for_page()
|
||||
self.assertEqual(browse_teams_page.header_name, 'Example Topic')
|
||||
self.assertEqual(browse_teams_page.header_description, 'Description')
|
||||
|
||||
def test_page_viewed_event(self):
|
||||
"""
|
||||
Scenario: Visiting the browse topics page should fire a page viewed event.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the browse topics page
|
||||
Then my browser should post a page viewed event
|
||||
"""
|
||||
topic = {u"name": u"Example Topic", u"id": u"example_topic", u"description": "Description"}
|
||||
self.set_team_configuration(
|
||||
{u"max_team_size": 1, u"topics": [topic]}
|
||||
)
|
||||
events = [{
|
||||
'event_type': 'edx.team.page_viewed',
|
||||
'event': {
|
||||
'page_name': 'browse',
|
||||
'topic_id': None,
|
||||
'team_id': None
|
||||
}
|
||||
}]
|
||||
with self.assert_events_match_during(self.only_team_events, expected_events=events):
|
||||
self.topics_page.visit()
|
||||
|
||||
|
||||
@attr(shard=4)
|
||||
@ddt.ddt
|
||||
class BrowseTeamsWithinTopicTest(TeamsTabBase):
|
||||
"""
|
||||
Tests for browsing Teams within a Topic on the Teams page.
|
||||
"""
|
||||
TEAMS_PAGE_SIZE = 10
|
||||
|
||||
def setUp(self):
|
||||
super(BrowseTeamsWithinTopicTest, self).setUp()
|
||||
self.topic = {u"name": u"Example Topic", u"id": "example_topic", u"description": "Description"}
|
||||
self.max_team_size = 10
|
||||
self.set_team_configuration({
|
||||
'course_id': self.course_id,
|
||||
'max_team_size': self.max_team_size,
|
||||
'topics': [self.topic]
|
||||
})
|
||||
self.browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, self.topic)
|
||||
self.topics_page = BrowseTopicsPage(self.browser, self.course_id)
|
||||
|
||||
def teams_with_default_sort_order(self, teams):
|
||||
"""Return a list of teams sorted according to the default ordering
|
||||
(last_activity_at, with a secondary sort by open slots).
|
||||
"""
|
||||
return sorted(
|
||||
sorted(teams, key=lambda t: len(t['membership']), reverse=True),
|
||||
key=lambda t: parse(t['last_activity_at']).replace(microsecond=0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
def verify_page_header(self):
|
||||
"""Verify that the page header correctly reflects the current topic's name and description."""
|
||||
self.assertEqual(self.browse_teams_page.header_name, self.topic['name'])
|
||||
self.assertEqual(self.browse_teams_page.header_description, self.topic['description'])
|
||||
|
||||
def verify_search_header(self, search_results_page, search_query):
|
||||
"""Verify that the page header correctly reflects the current topic's name and description."""
|
||||
self.assertEqual(search_results_page.header_name, 'Team Search')
|
||||
self.assertEqual(
|
||||
search_results_page.header_description,
|
||||
u'Showing results for "{search_query}"'.format(search_query=search_query)
|
||||
)
|
||||
|
||||
def verify_on_page(self, teams_page, page_num, total_teams, pagination_header_text, footer_visible):
|
||||
"""
|
||||
Verify that we are on the correct team list page.
|
||||
|
||||
Arguments:
|
||||
teams_page (BaseTeamsPage): The teams page object that should be the current page.
|
||||
page_num (int): The one-indexed page number that we expect to be on
|
||||
total_teams (list): An unsorted list of all the teams for the
|
||||
current topic
|
||||
pagination_header_text (str): Text we expect to see in the
|
||||
pagination header.
|
||||
footer_visible (bool): Whether we expect to see the pagination
|
||||
footer controls.
|
||||
"""
|
||||
sorted_teams = self.teams_with_default_sort_order(total_teams)
|
||||
self.assertTrue(teams_page.get_pagination_header_text().startswith(pagination_header_text))
|
||||
self.verify_teams(
|
||||
teams_page,
|
||||
sorted_teams[(page_num - 1) * self.TEAMS_PAGE_SIZE:page_num * self.TEAMS_PAGE_SIZE]
|
||||
)
|
||||
self.assertEqual(
|
||||
teams_page.pagination_controls_visible(),
|
||||
footer_visible,
|
||||
msg='Expected paging footer to be ' + 'visible' if footer_visible else 'invisible'
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
('open_slots', 'last_activity_at', True),
|
||||
('last_activity_at', 'open_slots', True)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_sort_teams(self, sort_order, secondary_sort_order, reverse):
|
||||
"""
|
||||
Scenario: the user should be able to sort the list of teams by open slots or last activity
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse teams within a topic
|
||||
Then I should see a list of teams for that topic
|
||||
When I choose a sort order
|
||||
Then I should see the paginated list of teams in that order
|
||||
"""
|
||||
teams = self.create_teams(self.topic, self.TEAMS_PAGE_SIZE + 1)
|
||||
for i, team in enumerate(random.sample(teams, len(teams))):
|
||||
for _ in range(i):
|
||||
user_info = AutoAuthPage(self.browser, course_id=self.course_id).visit().user_info
|
||||
self.create_membership(user_info['username'], team['id'])
|
||||
team['open_slots'] = self.max_team_size - i
|
||||
|
||||
# Re-authenticate as staff after creating users
|
||||
AutoAuthPage(
|
||||
self.browser,
|
||||
course_id=self.course_id,
|
||||
staff=True
|
||||
).visit()
|
||||
self.browse_teams_page.visit()
|
||||
self.browse_teams_page.sort_teams_by(sort_order)
|
||||
team_names = self.browse_teams_page.team_names
|
||||
self.assertEqual(len(team_names), self.TEAMS_PAGE_SIZE)
|
||||
sorted_teams = [
|
||||
team['name']
|
||||
for team in sorted(
|
||||
sorted(teams, key=lambda t: t[secondary_sort_order], reverse=reverse),
|
||||
key=lambda t: t[sort_order],
|
||||
reverse=reverse
|
||||
)
|
||||
][:self.TEAMS_PAGE_SIZE]
|
||||
self.assertEqual(team_names, sorted_teams)
|
||||
|
||||
def test_default_sort_order(self):
|
||||
"""
|
||||
Scenario: the list of teams should be sorted by last activity by default
|
||||
Given I am enrolled in a course with team configuration and topics
|
||||
When I visit the Teams page
|
||||
And I browse teams within a topic
|
||||
Then I should see a list of teams for that topic, sorted by last activity
|
||||
"""
|
||||
self.create_teams(self.topic, self.TEAMS_PAGE_SIZE + 1)
|
||||
self.browse_teams_page.visit()
|
||||
self.assertEqual(self.browse_teams_page.sort_order, 'last activity')
|
||||
|
||||
def test_no_teams(self):
|
||||
"""
|
||||
Scenario: Visiting a topic with no teams should not display any teams.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the Teams page for that topic
|
||||
Then I should see the correct page header
|
||||
And I should see a pagination header showing no teams
|
||||
And I should see no teams
|
||||
And I should see a button to add a team
|
||||
And I should not see a pagination footer
|
||||
"""
|
||||
self.browse_teams_page.visit()
|
||||
self.verify_page_header()
|
||||
self.assertTrue(self.browse_teams_page.get_pagination_header_text().startswith('Showing 0 out of 0 total'))
|
||||
self.assertEqual(len(self.browse_teams_page.team_cards), 0, msg='Expected to see no team cards')
|
||||
self.assertFalse(
|
||||
self.browse_teams_page.pagination_controls_visible(),
|
||||
msg='Expected paging footer to be invisible'
|
||||
)
|
||||
|
||||
def test_teams_one_page(self):
|
||||
"""
|
||||
Scenario: Visiting a topic with fewer teams than the page size should
|
||||
all those teams on one page.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the Teams page for that topic
|
||||
Then I should see the correct page header
|
||||
And I should see a pagination header showing the number of teams
|
||||
And I should see all the expected team cards
|
||||
And I should see a button to add a team
|
||||
And I should not see a pagination footer
|
||||
"""
|
||||
teams = self.teams_with_default_sort_order(
|
||||
self.create_teams(self.topic, self.TEAMS_PAGE_SIZE, time_between_creation=1)
|
||||
)
|
||||
self.browse_teams_page.visit()
|
||||
self.verify_page_header()
|
||||
self.assertTrue(self.browse_teams_page.get_pagination_header_text().startswith('Showing 1-10 out of 10 total'))
|
||||
self.verify_teams(self.browse_teams_page, teams)
|
||||
self.assertFalse(
|
||||
self.browse_teams_page.pagination_controls_visible(),
|
||||
msg='Expected paging footer to be invisible'
|
||||
)
|
||||
|
||||
def test_teams_navigation_buttons(self):
|
||||
"""
|
||||
Scenario: The user should be able to page through a topic's team list
|
||||
using navigation buttons when it is longer than the page size.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the Teams page for that topic
|
||||
Then I should see the correct page header
|
||||
And I should see that I am on the first page of results
|
||||
When I click on the next page button
|
||||
Then I should see that I am on the second page of results
|
||||
And when I click on the previous page button
|
||||
Then I should see that I am on the first page of results
|
||||
"""
|
||||
teams = self.create_teams(self.topic, self.TEAMS_PAGE_SIZE + 1, time_between_creation=1)
|
||||
self.browse_teams_page.visit()
|
||||
self.verify_page_header()
|
||||
self.verify_on_page(self.browse_teams_page, 1, teams, 'Showing 1-10 out of 11 total', True)
|
||||
self.browse_teams_page.press_next_page_button()
|
||||
self.verify_on_page(self.browse_teams_page, 2, teams, 'Showing 11-11 out of 11 total', True)
|
||||
self.browse_teams_page.press_previous_page_button()
|
||||
self.verify_on_page(self.browse_teams_page, 1, teams, 'Showing 1-10 out of 11 total', True)
|
||||
|
||||
def test_teams_page_input(self):
|
||||
"""
|
||||
Scenario: The user should be able to page through a topic's team list
|
||||
using the page input when it is longer than the page size.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the Teams page for that topic
|
||||
Then I should see the correct page header
|
||||
And I should see that I am on the first page of results
|
||||
When I input the second page
|
||||
Then I should see that I am on the second page of results
|
||||
When I input the first page
|
||||
Then I should see that I am on the first page of results
|
||||
"""
|
||||
teams = self.create_teams(self.topic, self.TEAMS_PAGE_SIZE + 10, time_between_creation=1)
|
||||
self.browse_teams_page.visit()
|
||||
self.verify_page_header()
|
||||
self.verify_on_page(self.browse_teams_page, 1, teams, 'Showing 1-10 out of 20 total', True)
|
||||
self.browse_teams_page.go_to_page(2)
|
||||
self.verify_on_page(self.browse_teams_page, 2, teams, 'Showing 11-20 out of 20 total', True)
|
||||
self.browse_teams_page.go_to_page(1)
|
||||
self.verify_on_page(self.browse_teams_page, 1, teams, 'Showing 1-10 out of 20 total', True)
|
||||
|
||||
def test_browse_team_topics(self):
|
||||
"""
|
||||
Scenario: User should be able to navigate to "browse all teams" and "search team description" links.
|
||||
Given I am enrolled in a course with teams enabled
|
||||
When I visit the Teams page for a topic
|
||||
Then I should see the correct page header
|
||||
And I should see the link to "browse teams in other topics"
|
||||
When I should navigate to that link
|
||||
Then I should see the topic browse page
|
||||
"""
|
||||
self.browse_teams_page.visit()
|
||||
self.verify_page_header()
|
||||
|
||||
self.browse_teams_page.click_browse_all_teams_link()
|
||||
self.topics_page.wait_for_page()
|
||||
|
||||
def test_search(self):
|
||||
"""
|
||||
Scenario: User should be able to search for a team
|
||||
Given I am enrolled in a course with teams enabled
|
||||
When I visit the Teams page for that topic
|
||||
And I search for 'banana'
|
||||
Then I should see the search result page
|
||||
And the search header should be shown
|
||||
And 0 results should be shown
|
||||
And my browser should fire a page viewed event for the search page
|
||||
And a searched event should have been fired
|
||||
"""
|
||||
# Note: all searches will return 0 results with the mock search server
|
||||
# used by Bok Choy.
|
||||
search_text = 'banana'
|
||||
self.create_teams(self.topic, 5)
|
||||
self.browse_teams_page.visit()
|
||||
events = [{
|
||||
'event_type': 'edx.team.page_viewed',
|
||||
'event': {
|
||||
'page_name': 'search-teams',
|
||||
'topic_id': self.topic['id'],
|
||||
'team_id': None
|
||||
}
|
||||
}, {
|
||||
'event_type': 'edx.team.searched',
|
||||
'event': {
|
||||
'search_text': search_text,
|
||||
'topic_id': self.topic['id'],
|
||||
'number_of_results': 0
|
||||
}
|
||||
}]
|
||||
with self.assert_events_match_during(self.only_team_events, expected_events=events, in_order=False):
|
||||
search_results_page = self.browse_teams_page.search(search_text)
|
||||
self.verify_search_header(search_results_page, search_text)
|
||||
self.assertTrue(search_results_page.get_pagination_header_text().startswith('Showing 0 out of 0 total'))
|
||||
|
||||
def test_page_viewed_event(self):
|
||||
"""
|
||||
Scenario: Visiting the browse page should fire a page viewed event.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the Teams page
|
||||
Then my browser should post a page viewed event for the teams page
|
||||
"""
|
||||
self.create_teams(self.topic, 5)
|
||||
events = [{
|
||||
'event_type': 'edx.team.page_viewed',
|
||||
'event': {
|
||||
'page_name': 'single-topic',
|
||||
'topic_id': self.topic['id'],
|
||||
'team_id': None
|
||||
}
|
||||
}]
|
||||
with self.assert_events_match_during(self.only_team_events, expected_events=events):
|
||||
self.browse_teams_page.visit()
|
||||
|
||||
def test_team_name_xss(self):
|
||||
"""
|
||||
Scenario: Team names should be HTML-escaped on the teams page
|
||||
Given I am enrolled in a course with teams enabled
|
||||
When I visit the Teams page for a topic, with a team name containing JS code
|
||||
Then I should not see any alerts
|
||||
"""
|
||||
self.post_team_data({
|
||||
'course_id': self.course_id,
|
||||
'topic_id': self.topic['id'],
|
||||
'name': '<script>alert("XSS")</script>',
|
||||
'description': 'Description',
|
||||
'language': 'aa',
|
||||
'country': 'AF'
|
||||
})
|
||||
with self.assertRaises(TimeoutException):
|
||||
self.browser.get(self.browse_teams_page.url)
|
||||
alert = get_modal_alert(self.browser)
|
||||
alert.accept()
|
||||
|
||||
|
||||
class TeamFormActions(TeamsTabBase):
|
||||
"""
|
||||
Base class for create, edit, and delete team.
|
||||
"""
|
||||
TEAM_DESCRIPTION = 'The Avengers are a fictional team of superheroes.'
|
||||
|
||||
topic = {'name': 'Example Topic', 'id': 'example_topic', 'description': 'Description'}
|
||||
TEAMS_NAME = 'Avengers'
|
||||
|
||||
def setUp(self):
|
||||
super(TeamFormActions, self).setUp()
|
||||
self.team_management_page = TeamManagementPage(self.browser, self.course_id, self.topic)
|
||||
|
||||
def verify_page_header(self, title, description, breadcrumbs):
|
||||
"""
|
||||
Verify that the page header correctly reflects the
|
||||
create team header, description and breadcrumb.
|
||||
"""
|
||||
self.assertEqual(self.team_management_page.header_page_name, title)
|
||||
self.assertEqual(self.team_management_page.header_page_description, description)
|
||||
self.assertEqual(self.team_management_page.header_page_breadcrumbs, breadcrumbs)
|
||||
|
||||
def verify_and_navigate_to_create_team_page(self):
|
||||
"""Navigates to the create team page and verifies."""
|
||||
self.browse_teams_page.click_create_team_link()
|
||||
self.verify_page_header(
|
||||
title='Create a New Team',
|
||||
description='Create a new team if you can\'t find an existing team to join, '
|
||||
'or if you would like to learn with friends you know.',
|
||||
breadcrumbs=u'All Topics {topic_name}'.format(topic_name=self.topic['name'])
|
||||
)
|
||||
|
||||
def verify_and_navigate_to_edit_team_page(self):
|
||||
"""Navigates to the edit team page and verifies."""
|
||||
self.assertEqual(self.team_page.team_name, self.team['name'])
|
||||
self.assertTrue(self.team_page.edit_team_button_present)
|
||||
|
||||
self.team_page.click_edit_team_button()
|
||||
|
||||
self.team_management_page.wait_for_page()
|
||||
|
||||
# Edit page header.
|
||||
self.verify_page_header(
|
||||
title='Edit Team',
|
||||
description='If you make significant changes, make sure you notify '
|
||||
'members of the team before making these changes.',
|
||||
breadcrumbs=u'All Topics {topic_name} {team_name}'.format(
|
||||
topic_name=self.topic['name'],
|
||||
team_name=self.team['name']
|
||||
)
|
||||
)
|
||||
|
||||
def verify_team_info(self, name, description, location, language):
|
||||
"""Verify the team information on team page."""
|
||||
self.assertEqual(self.team_page.team_name, name)
|
||||
self.assertEqual(self.team_page.team_description, description)
|
||||
self.assertEqual(self.team_page.team_location, location)
|
||||
self.assertEqual(self.team_page.team_language, language)
|
||||
|
||||
def fill_create_or_edit_form(self):
|
||||
"""Fill the create/edit team form fields with appropriate values."""
|
||||
self.team_management_page.value_for_text_field(
|
||||
field_id='name',
|
||||
value=self.TEAMS_NAME,
|
||||
press_enter=False
|
||||
)
|
||||
self.team_management_page.set_value_for_textarea_field(
|
||||
field_id='description',
|
||||
value=self.TEAM_DESCRIPTION
|
||||
)
|
||||
self.team_management_page.value_for_dropdown_field(field_id='language', value='English')
|
||||
self.team_management_page.value_for_dropdown_field(field_id='country', value='Pakistan')
|
||||
|
||||
def verify_all_fields_exist(self):
|
||||
"""
|
||||
Verify the fields for create/edit page.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.team_management_page.message_for_field('name'),
|
||||
'A name that identifies your team (maximum 255 characters).'
|
||||
)
|
||||
self.assertEqual(
|
||||
self.team_management_page.message_for_textarea_field('description'),
|
||||
'A short description of the team to help other learners understand '
|
||||
'the goals or direction of the team (maximum 300 characters).'
|
||||
)
|
||||
self.assertEqual(
|
||||
self.team_management_page.message_for_field('country'),
|
||||
'The country that team members primarily identify with.'
|
||||
)
|
||||
self.assertEqual(
|
||||
self.team_management_page.message_for_field('language'),
|
||||
'The language that team members primarily use to communicate with each other.'
|
||||
)
|
||||
|
||||
|
||||
@attr(shard=4)
|
||||
@ddt.ddt
|
||||
class CreateTeamTest(TeamFormActions):
|
||||
"""
|
||||
Tests for creating a new Team within a Topic on the Teams page.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(CreateTeamTest, self).setUp()
|
||||
self.set_team_configuration({'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]})
|
||||
|
||||
self.browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, self.topic)
|
||||
self.browse_teams_page.visit()
|
||||
|
||||
def test_user_can_see_create_team_page(self):
|
||||
"""
|
||||
Scenario: The user should be able to see the create team page via teams list page.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the Teams page for that topic
|
||||
Then I should see the Create Team page link on bottom
|
||||
And When I click create team link
|
||||
Then I should see the create team page.
|
||||
And I should see the create team header
|
||||
And I should also see the help messages for fields.
|
||||
"""
|
||||
self.verify_and_navigate_to_create_team_page()
|
||||
self.verify_all_fields_exist()
|
||||
|
||||
def test_user_can_see_error_message_for_missing_data(self):
|
||||
"""
|
||||
Scenario: The user should be able to see error message in case of missing required field.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the Create Team page for that topic
|
||||
Then I should see the Create Team header and form
|
||||
And When I click create team button without filling required fields
|
||||
Then I should see the error message and highlighted fields.
|
||||
"""
|
||||
self.verify_and_navigate_to_create_team_page()
|
||||
|
||||
# `submit_form` clicks on a button, but that button doesn't always
|
||||
# have the click event handler registered on it in time. That's why
|
||||
# this test is flaky. Unfortunately, I don't know of a straightforward
|
||||
# way to write something that waits for that event handler to be bound
|
||||
# to the button element. So I used time.sleep as well, even though
|
||||
# the bok choy docs explicitly ask us not to:
|
||||
# https://bok-choy.readthedocs.io/en/latest/guidelines.html
|
||||
# Sorry! For the story to address this anti-pattern, see TNL-5820
|
||||
time.sleep(0.5)
|
||||
self.team_management_page.submit_form()
|
||||
self.team_management_page.wait_for(
|
||||
lambda: self.team_management_page.validation_message_text,
|
||||
"Validation message text never loaded."
|
||||
)
|
||||
self.assertEqual(
|
||||
self.team_management_page.validation_message_text,
|
||||
'Check the highlighted fields below and try again.'
|
||||
)
|
||||
self.assertTrue(self.team_management_page.error_for_field(field_id='name'))
|
||||
self.assertTrue(self.team_management_page.error_for_field(field_id='description'))
|
||||
|
||||
def test_user_can_see_error_message_for_incorrect_data(self):
|
||||
"""
|
||||
Scenario: The user should be able to see error message in case of increasing length for required fields.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the Create Team page for that topic
|
||||
Then I should see the Create Team header and form
|
||||
When I add text > than 255 characters for name field
|
||||
And I click Create button
|
||||
Then I should see the error message for exceeding length.
|
||||
"""
|
||||
self.verify_and_navigate_to_create_team_page()
|
||||
|
||||
# Fill the name field with >255 characters to see validation message.
|
||||
self.team_management_page.value_for_text_field(
|
||||
field_id='name',
|
||||
value='EdX is a massive open online course (MOOC) provider and online learning platform. '
|
||||
'It hosts online university-level courses in a wide range of disciplines to a worldwide '
|
||||
'audience, some at no charge. It also conducts research into learning based on how '
|
||||
'people use its platform. EdX was created for students and institutions that seek to'
|
||||
'transform themselves through cutting-edge technologies, innovative pedagogy, and '
|
||||
'rigorous courses. More than 70 schools, nonprofits, corporations, and international'
|
||||
'organizations offer or plan to offer courses on the edX website. As of 22 October 2014,'
|
||||
'edX has more than 4 million users taking more than 500 courses online.',
|
||||
press_enter=False
|
||||
)
|
||||
self.team_management_page.submit_form()
|
||||
|
||||
self.assertEqual(
|
||||
self.team_management_page.validation_message_text,
|
||||
'Check the highlighted fields below and try again.'
|
||||
)
|
||||
self.assertTrue(self.team_management_page.error_for_field(field_id='name'))
|
||||
|
||||
def test_user_can_create_new_team_successfully(self):
|
||||
"""
|
||||
Scenario: The user should be able to create new team.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the Create Team page for that topic
|
||||
Then I should see the Create Team header and form
|
||||
When I fill all the fields present with appropriate data
|
||||
And I click Create button
|
||||
Then I expect analytics events to be emitted
|
||||
And I should see the page for my team
|
||||
And I should see the message that says "You are member of this team"
|
||||
And the new team should be added to the list of teams within the topic
|
||||
And the number of teams should be updated on the topic card
|
||||
And if I switch to "My Team", the newly created team is displayed
|
||||
"""
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
self.browse_teams_page.visit()
|
||||
|
||||
self.verify_and_navigate_to_create_team_page()
|
||||
|
||||
self.fill_create_or_edit_form()
|
||||
|
||||
expected_events = [
|
||||
{
|
||||
'event_type': 'edx.team.created'
|
||||
},
|
||||
{
|
||||
'event_type': 'edx.team.learner_added',
|
||||
'event': {
|
||||
'add_method': 'added_on_create',
|
||||
}
|
||||
}
|
||||
]
|
||||
with self.assert_events_match_during(event_filter=self.only_team_events, expected_events=expected_events):
|
||||
self.team_management_page.submit_form()
|
||||
|
||||
# Verify that the page is shown for the new team
|
||||
team_page = TeamPage(self.browser, self.course_id)
|
||||
team_page.wait_for_page()
|
||||
self.assertEqual(team_page.team_name, self.TEAMS_NAME)
|
||||
self.assertEqual(team_page.team_description, self.TEAM_DESCRIPTION)
|
||||
self.assertEqual(team_page.team_user_membership_text, 'You are a member of this team.')
|
||||
|
||||
# Verify the new team was added to the topic list
|
||||
self.teams_page.click_specific_topic("Example Topic")
|
||||
self.teams_page.verify_topic_team_count(1)
|
||||
|
||||
self.teams_page.click_all_topics()
|
||||
self.teams_page.verify_team_count_in_first_topic(1)
|
||||
|
||||
# Verify that if one switches to "My Team" without reloading the page, the newly created team is shown.
|
||||
self.verify_my_team_count(1)
|
||||
|
||||
def test_user_can_cancel_the_team_creation(self):
|
||||
"""
|
||||
Scenario: The user should be able to cancel the creation of new team.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the Create Team page for that topic
|
||||
Then I should see the Create Team header and form
|
||||
When I click Cancel button
|
||||
Then I should see teams list page without any new team.
|
||||
And if I switch to "My Team", it shows no teams
|
||||
"""
|
||||
self.assertTrue(self.browse_teams_page.get_pagination_header_text().startswith('Showing 0 out of 0 total'))
|
||||
|
||||
self.verify_and_navigate_to_create_team_page()
|
||||
|
||||
# We add a sleep here to allow time for the click event handler to bind
|
||||
# to the cancel button. Using time.sleep in bok-choy tests is,
|
||||
# generally, an anti-pattern. So don't copy this :).
|
||||
# For the story to address this anti-pattern, see TNL-5820
|
||||
time.sleep(0.5)
|
||||
|
||||
self.team_management_page.cancel_team()
|
||||
|
||||
self.browse_teams_page.wait_for_page()
|
||||
self.assertTrue(self.browse_teams_page.get_pagination_header_text().startswith('Showing 0 out of 0 total'))
|
||||
|
||||
self.teams_page.click_all_topics()
|
||||
self.teams_page.verify_team_count_in_first_topic(0)
|
||||
|
||||
self.verify_my_team_count(0)
|
||||
|
||||
def test_page_viewed_event(self):
|
||||
"""
|
||||
Scenario: Visiting the create team page should fire a page viewed event.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the create team page
|
||||
Then my browser should post a page viewed event
|
||||
"""
|
||||
events = [{
|
||||
'event_type': 'edx.team.page_viewed',
|
||||
'event': {
|
||||
'page_name': 'new-team',
|
||||
'topic_id': self.topic['id'],
|
||||
'team_id': None
|
||||
}
|
||||
}]
|
||||
with self.assert_events_match_during(self.only_team_events, expected_events=events):
|
||||
self.verify_and_navigate_to_create_team_page()
|
||||
|
||||
|
||||
@attr(shard=21)
|
||||
@ddt.ddt
|
||||
class DeleteTeamTest(TeamFormActions):
|
||||
"""
|
||||
Tests for deleting teams.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(DeleteTeamTest, self).setUp()
|
||||
|
||||
self.set_team_configuration(
|
||||
{'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]},
|
||||
global_staff=True
|
||||
)
|
||||
|
||||
self.team = self.create_teams(self.topic, num_teams=1)[0]
|
||||
self.team_page = TeamPage(self.browser, self.course_id, team=self.team)
|
||||
|
||||
#need to have a membership to confirm it gets deleted as well
|
||||
self.create_membership(self.user_info['username'], self.team['id'])
|
||||
|
||||
self.team_page.visit()
|
||||
|
||||
def test_cancel_delete(self):
|
||||
"""
|
||||
Scenario: The user should be able to cancel the Delete Team dialog
|
||||
Given I am staff user for a course with a team
|
||||
When I visit the Team profile page
|
||||
Then I should see the Edit Team button
|
||||
And When I click edit team button
|
||||
Then I should see the Delete Team button
|
||||
When I click the delete team button
|
||||
And I cancel the prompt
|
||||
And I refresh the page
|
||||
Then I should still see the team
|
||||
"""
|
||||
self.delete_team(cancel=True)
|
||||
self.team_management_page.wait_for_page()
|
||||
self.browser.refresh()
|
||||
self.team_management_page.wait_for_page()
|
||||
self.assertEqual(
|
||||
' '.join(('All Topics', self.topic['name'], self.team['name'])),
|
||||
self.team_management_page.header_page_breadcrumbs
|
||||
)
|
||||
|
||||
@ddt.data('Moderator', 'Community TA', 'Administrator', None)
|
||||
def test_delete_team(self, role):
|
||||
"""
|
||||
Scenario: The user should be able to see and navigate to the delete team page.
|
||||
Given I am staff user for a course with a team
|
||||
When I visit the Team profile page
|
||||
Then I should see the Edit Team button
|
||||
And When I click edit team button
|
||||
Then I should see the Delete Team button
|
||||
When I click the delete team button
|
||||
And I confirm the prompt
|
||||
Then I should see the browse teams page
|
||||
And the team should not be present
|
||||
"""
|
||||
# If role is None, remain logged in as global staff
|
||||
if role is not None:
|
||||
AutoAuthPage(
|
||||
self.browser,
|
||||
course_id=self.course_id,
|
||||
staff=False,
|
||||
roles=role
|
||||
).visit()
|
||||
self.team_page.visit()
|
||||
self.delete_team(require_notification=False)
|
||||
browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, self.topic)
|
||||
browse_teams_page.wait_for_page()
|
||||
self.assertNotIn(self.team['name'], browse_teams_page.team_names)
|
||||
|
||||
def delete_team(self, **kwargs):
|
||||
"""
|
||||
Delete a team. Passes `kwargs` to `confirm_prompt`.
|
||||
Expects edx.team.deleted event to be emitted, with correct course_id.
|
||||
Also expects edx.team.learner_removed event to be emitted for the
|
||||
membership that is removed as a part of the delete operation.
|
||||
"""
|
||||
|
||||
self.team_page.click_edit_team_button()
|
||||
self.team_management_page.wait_for_page()
|
||||
self.team_management_page.delete_team_button.click()
|
||||
|
||||
if 'cancel' in kwargs and kwargs['cancel'] is True:
|
||||
confirm_prompt(self.team_management_page, **kwargs)
|
||||
else:
|
||||
expected_events = [
|
||||
{
|
||||
'event_type': 'edx.team.deleted',
|
||||
'event': {
|
||||
'team_id': self.team['id']
|
||||
}
|
||||
},
|
||||
{
|
||||
'event_type': 'edx.team.learner_removed',
|
||||
'event': {
|
||||
'team_id': self.team['id'],
|
||||
'remove_method': 'team_deleted',
|
||||
'user_id': self.user_info['user_id']
|
||||
}
|
||||
}
|
||||
]
|
||||
with self.assert_events_match_during(
|
||||
event_filter=self.only_team_events, expected_events=expected_events
|
||||
):
|
||||
confirm_prompt(self.team_management_page, **kwargs)
|
||||
|
||||
def test_delete_team_updates_topics(self):
|
||||
"""
|
||||
Scenario: Deleting a team should update the team count on the topics page
|
||||
Given I am staff user for a course with a team
|
||||
And I delete a team
|
||||
When I navigate to the browse topics page
|
||||
Then the team count for the deletd team's topic should be updated
|
||||
"""
|
||||
self.delete_team(require_notification=False)
|
||||
BrowseTeamsPage(self.browser, self.course_id, self.topic).click_all_topics()
|
||||
topics_page = BrowseTopicsPage(self.browser, self.course_id)
|
||||
topics_page.wait_for_page()
|
||||
self.teams_page.verify_topic_team_count(0)
|
||||
|
||||
|
||||
@attr(shard=17)
|
||||
@ddt.ddt
|
||||
class EditTeamTest(TeamFormActions):
|
||||
"""
|
||||
Tests for editing the team.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(EditTeamTest, self).setUp()
|
||||
|
||||
self.set_team_configuration(
|
||||
{'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]},
|
||||
global_staff=True
|
||||
)
|
||||
|
||||
self.team = self.create_teams(self.topic, num_teams=1)[0]
|
||||
self.team_page = TeamPage(self.browser, self.course_id, team=self.team)
|
||||
self.team_page.visit()
|
||||
|
||||
def test_staff_can_navigate_to_edit_team_page(self):
|
||||
"""
|
||||
Scenario: The user should be able to see and navigate to the edit team page.
|
||||
Given I am staff user for a course with a team
|
||||
When I visit the Team profile page
|
||||
Then I should see the Edit Team button
|
||||
And When I click edit team button
|
||||
Then I should see the edit team page
|
||||
And I should see the edit team header
|
||||
And I should also see the help messages for fields
|
||||
"""
|
||||
self.verify_and_navigate_to_edit_team_page()
|
||||
self.verify_all_fields_exist()
|
||||
|
||||
def test_staff_can_edit_team_successfully(self):
|
||||
"""
|
||||
Scenario: The staff should be able to edit team successfully.
|
||||
Given I am staff user for a course with a team
|
||||
When I visit the Team profile page
|
||||
Then I should see the Edit Team button
|
||||
And When I click edit team button
|
||||
Then I should see the edit team page
|
||||
And an analytics event should be fired
|
||||
When I edit all the fields with appropriate data
|
||||
And I click Update button
|
||||
Then I should see the page for my team with updated data
|
||||
"""
|
||||
self.verify_team_info(
|
||||
name=self.team['name'],
|
||||
description=self.team['description'],
|
||||
location='Afghanistan',
|
||||
language='Afar'
|
||||
)
|
||||
self.verify_and_navigate_to_edit_team_page()
|
||||
|
||||
self.fill_create_or_edit_form()
|
||||
|
||||
expected_events = [
|
||||
{
|
||||
'event_type': 'edx.team.changed',
|
||||
'event': {
|
||||
'team_id': self.team['id'],
|
||||
'field': 'country',
|
||||
'old': 'AF',
|
||||
'new': 'PK',
|
||||
'truncated': [],
|
||||
}
|
||||
},
|
||||
{
|
||||
'event_type': 'edx.team.changed',
|
||||
'event': {
|
||||
'team_id': self.team['id'],
|
||||
'field': 'name',
|
||||
'old': self.team['name'],
|
||||
'new': self.TEAMS_NAME,
|
||||
'truncated': [],
|
||||
}
|
||||
},
|
||||
{
|
||||
'event_type': 'edx.team.changed',
|
||||
'event': {
|
||||
'team_id': self.team['id'],
|
||||
'field': 'language',
|
||||
'old': 'aa',
|
||||
'new': 'en',
|
||||
'truncated': [],
|
||||
}
|
||||
},
|
||||
{
|
||||
'event_type': 'edx.team.changed',
|
||||
'event': {
|
||||
'team_id': self.team['id'],
|
||||
'field': 'description',
|
||||
'old': self.team['description'],
|
||||
'new': self.TEAM_DESCRIPTION,
|
||||
'truncated': [],
|
||||
}
|
||||
},
|
||||
]
|
||||
with self.assert_events_match_during(
|
||||
event_filter=self.only_team_events,
|
||||
expected_events=expected_events,
|
||||
):
|
||||
self.team_management_page.submit_form()
|
||||
|
||||
self.team_page.wait_for_page()
|
||||
|
||||
self.verify_team_info(
|
||||
name=self.TEAMS_NAME,
|
||||
description=self.TEAM_DESCRIPTION,
|
||||
location='Pakistan',
|
||||
language='English'
|
||||
)
|
||||
|
||||
def test_staff_can_cancel_the_team_edit(self):
|
||||
"""
|
||||
Scenario: The user should be able to cancel the editing of team.
|
||||
Given I am staff user for a course with a team
|
||||
When I visit the Team profile page
|
||||
Then I should see the Edit Team button
|
||||
And When I click edit team button
|
||||
Then I should see the edit team page
|
||||
Then I should see the Edit Team header
|
||||
When I click Cancel button
|
||||
Then I should see team page page without changes.
|
||||
"""
|
||||
self.verify_team_info(
|
||||
name=self.team['name'],
|
||||
description=self.team['description'],
|
||||
location='Afghanistan',
|
||||
language='Afar'
|
||||
)
|
||||
|
||||
self.verify_and_navigate_to_edit_team_page()
|
||||
|
||||
self.fill_create_or_edit_form()
|
||||
self.team_management_page.cancel_team()
|
||||
|
||||
self.team_page.wait_for_page()
|
||||
|
||||
self.verify_team_info(
|
||||
name=self.team['name'],
|
||||
description=self.team['description'],
|
||||
location='Afghanistan',
|
||||
language='Afar'
|
||||
)
|
||||
|
||||
def test_student_cannot_see_edit_button(self):
|
||||
"""
|
||||
Scenario: The student should not see the edit team button.
|
||||
Given I am student for a course with a team
|
||||
When I visit the Team profile page
|
||||
Then I should not see the Edit Team button
|
||||
"""
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
self.team_page.visit()
|
||||
self.assertFalse(self.team_page.edit_team_button_present)
|
||||
|
||||
@ddt.data('Moderator', 'Community TA', 'Administrator')
|
||||
def test_discussion_privileged_user_can_edit_team(self, role):
|
||||
"""
|
||||
Scenario: The user with specified role should see the edit team button.
|
||||
Given I am user with privileged role for a course with a team
|
||||
When I visit the Team profile page
|
||||
Then I should see the Edit Team button
|
||||
"""
|
||||
kwargs = {
|
||||
'course_id': self.course_id,
|
||||
'staff': False
|
||||
}
|
||||
if role is not None:
|
||||
kwargs['roles'] = role
|
||||
|
||||
AutoAuthPage(self.browser, **kwargs).visit()
|
||||
|
||||
self.team_page.visit()
|
||||
self.teams_page.wait_for_page()
|
||||
self.assertTrue(self.team_page.edit_team_button_present)
|
||||
|
||||
self.verify_team_info(
|
||||
name=self.team['name'],
|
||||
description=self.team['description'],
|
||||
location='Afghanistan',
|
||||
language='Afar'
|
||||
)
|
||||
self.verify_and_navigate_to_edit_team_page()
|
||||
|
||||
self.fill_create_or_edit_form()
|
||||
self.team_management_page.submit_form()
|
||||
|
||||
self.team_page.wait_for_page()
|
||||
|
||||
self.verify_team_info(
|
||||
name=self.TEAMS_NAME,
|
||||
description=self.TEAM_DESCRIPTION,
|
||||
location='Pakistan',
|
||||
language='English'
|
||||
)
|
||||
|
||||
def test_page_viewed_event(self):
|
||||
"""
|
||||
Scenario: Visiting the edit team page should fire a page viewed event.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the edit team page
|
||||
Then my browser should post a page viewed event
|
||||
"""
|
||||
events = [{
|
||||
'event_type': 'edx.team.page_viewed',
|
||||
'event': {
|
||||
'page_name': 'edit-team',
|
||||
'topic_id': self.topic['id'],
|
||||
'team_id': self.team['id']
|
||||
}
|
||||
}]
|
||||
with self.assert_events_match_during(self.only_team_events, expected_events=events):
|
||||
self.verify_and_navigate_to_edit_team_page()
|
||||
|
||||
|
||||
@attr(shard=17)
|
||||
@ddt.ddt
|
||||
class EditMembershipTest(TeamFormActions):
|
||||
"""
|
||||
Tests for administrating from the team membership page
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(EditMembershipTest, self).setUp()
|
||||
|
||||
self.set_team_configuration(
|
||||
{'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]},
|
||||
global_staff=True
|
||||
)
|
||||
self.team_management_page = TeamManagementPage(self.browser, self.course_id, self.topic)
|
||||
self.team = self.create_teams(self.topic, num_teams=1)[0]
|
||||
|
||||
#make sure a user exists on this team so we can edit the membership
|
||||
self.create_membership(self.user_info['username'], self.team['id'])
|
||||
|
||||
self.edit_membership_page = EditMembershipPage(self.browser, self.course_id, self.team)
|
||||
self.team_page = TeamPage(self.browser, self.course_id, team=self.team)
|
||||
|
||||
def edit_membership_helper(self, role, cancel=False):
|
||||
"""
|
||||
Helper for common functionality in edit membership tests.
|
||||
Checks for all relevant assertions about membership being removed,
|
||||
including verify edx.team.learner_removed events are emitted.
|
||||
"""
|
||||
if role is not None:
|
||||
AutoAuthPage(
|
||||
self.browser,
|
||||
course_id=self.course_id,
|
||||
staff=False,
|
||||
roles=role
|
||||
).visit()
|
||||
|
||||
self.team_page.visit()
|
||||
self.team_page.click_edit_team_button()
|
||||
self.team_management_page.wait_for_page()
|
||||
|
||||
self.assertTrue(
|
||||
self.team_management_page.membership_button_present
|
||||
)
|
||||
|
||||
self.team_management_page.click_membership_button()
|
||||
self.edit_membership_page.wait_for_page()
|
||||
self.edit_membership_page.click_first_remove()
|
||||
if cancel:
|
||||
self.edit_membership_page.cancel_delete_membership_dialog()
|
||||
self.assertEqual(self.edit_membership_page.team_members, 1)
|
||||
else:
|
||||
expected_events = [
|
||||
{
|
||||
'event_type': 'edx.team.learner_removed',
|
||||
'event': {
|
||||
'team_id': self.team['id'],
|
||||
'remove_method': 'removed_by_admin',
|
||||
'user_id': self.user_info['user_id']
|
||||
}
|
||||
}
|
||||
]
|
||||
with self.assert_events_match_during(
|
||||
event_filter=self.only_team_events, expected_events=expected_events
|
||||
):
|
||||
self.edit_membership_page.confirm_delete_membership_dialog()
|
||||
self.assertEqual(self.edit_membership_page.team_members, 0)
|
||||
self.edit_membership_page.wait_for_page()
|
||||
|
||||
@ddt.data('Moderator', 'Community TA', 'Administrator', None)
|
||||
def test_remove_membership(self, role):
|
||||
"""
|
||||
Scenario: The user should be able to remove a membership
|
||||
Given I am staff user for a course with a team
|
||||
When I visit the Team profile page
|
||||
Then I should see the Edit Team button
|
||||
And When I click edit team button
|
||||
Then I should see the Edit Membership button
|
||||
And When I click the edit membership button
|
||||
Then I should see the edit membership page
|
||||
And When I click the remove button and confirm the dialog
|
||||
Then my membership should be removed, and I should remain on the page
|
||||
"""
|
||||
self.edit_membership_helper(role, cancel=False)
|
||||
|
||||
@ddt.data('Moderator', 'Community TA', 'Administrator', None)
|
||||
def test_cancel_remove_membership(self, role):
|
||||
"""
|
||||
Scenario: The user should be able to remove a membership
|
||||
Given I am staff user for a course with a team
|
||||
When I visit the Team profile page
|
||||
Then I should see the Edit Team button
|
||||
And When I click edit team button
|
||||
Then I should see the Edit Membership button
|
||||
And When I click the edit membership button
|
||||
Then I should see the edit membership page
|
||||
And When I click the remove button and cancel the dialog
|
||||
Then my membership should not be removed, and I should remain on the page
|
||||
"""
|
||||
self.edit_membership_helper(role, cancel=True)
|
||||
|
||||
|
||||
@attr(shard=17)
|
||||
@ddt.ddt
|
||||
class TeamPageTest(TeamsTabBase):
|
||||
"""Tests for viewing a specific team"""
|
||||
|
||||
SEND_INVITE_TEXT = 'Send this link to friends so that they can join too.'
|
||||
|
||||
def setUp(self):
|
||||
super(TeamPageTest, self).setUp()
|
||||
self.topic = {u"name": u"Example Topic", u"id": "example_topic", u"description": "Description"}
|
||||
|
||||
def _set_team_configuration_and_membership(
|
||||
self,
|
||||
max_team_size=10,
|
||||
membership_team_index=0,
|
||||
visit_team_index=0,
|
||||
create_membership=True,
|
||||
another_user=False):
|
||||
"""
|
||||
Set team configuration.
|
||||
|
||||
Arguments:
|
||||
max_team_size (int): number of users a team can have
|
||||
membership_team_index (int): index of team user will join
|
||||
visit_team_index (int): index of team user will visit
|
||||
create_membership (bool): whether to create membership or not
|
||||
another_user (bool): another user to visit a team
|
||||
"""
|
||||
#pylint: disable=attribute-defined-outside-init
|
||||
self.set_team_configuration(
|
||||
{'course_id': self.course_id, 'max_team_size': max_team_size, 'topics': [self.topic]}
|
||||
)
|
||||
self.teams = self.create_teams(self.topic, 2)
|
||||
|
||||
if create_membership:
|
||||
self.create_membership(self.user_info['username'], self.teams[membership_team_index]['id'])
|
||||
|
||||
if another_user:
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
self.team_page = TeamPage(self.browser, self.course_id, self.teams[visit_team_index])
|
||||
|
||||
def setup_thread(self):
|
||||
"""
|
||||
Create and return a thread for this test's discussion topic.
|
||||
"""
|
||||
thread = Thread(
|
||||
id="test_thread_{}".format(uuid4().hex),
|
||||
commentable_id=self.teams[0]['discussion_topic_id'],
|
||||
body="Dummy text body.",
|
||||
context="standalone",
|
||||
)
|
||||
thread_fixture = MultipleThreadFixture([thread])
|
||||
thread_fixture.push()
|
||||
return thread
|
||||
|
||||
def setup_discussion_user(self, role=None, staff=False):
|
||||
"""Set this test's user to have the given role in its
|
||||
discussions. Role is one of 'Community TA', 'Moderator',
|
||||
'Administrator', or 'Student'.
|
||||
"""
|
||||
kwargs = {
|
||||
'course_id': self.course_id,
|
||||
'staff': staff
|
||||
}
|
||||
if role is not None:
|
||||
kwargs['roles'] = role
|
||||
#pylint: disable=attribute-defined-outside-init
|
||||
self.user_info = AutoAuthPage(self.browser, **kwargs).visit().user_info
|
||||
|
||||
def verify_teams_discussion_permissions(self, should_have_permission):
|
||||
"""Verify that the teams discussion component is in the correct state
|
||||
for the test user. If `should_have_permission` is True, assert that
|
||||
the user can see controls for posting replies, voting, editing, and
|
||||
deleting. Otherwise, assert that those controls are hidden.
|
||||
"""
|
||||
thread = self.setup_thread()
|
||||
self.team_page.visit()
|
||||
self.assertEqual(self.team_page.discussion_id, self.teams[0]['discussion_topic_id'])
|
||||
discussion_page = self.team_page.discussion_page
|
||||
discussion_page.wait_for_page()
|
||||
self.assertTrue(discussion_page.is_discussion_expanded())
|
||||
self.assertEqual(discussion_page.get_num_displayed_threads(), 1)
|
||||
discussion_page.show_thread(thread['id'])
|
||||
thread_page = discussion_page.thread_page
|
||||
assertion = self.assertTrue if should_have_permission else self.assertFalse
|
||||
assertion(thread_page.q(css='.post-header-actions').present)
|
||||
assertion(thread_page.q(css='.add-response').present)
|
||||
|
||||
def test_discussion_on_my_team_page(self):
|
||||
"""
|
||||
Scenario: Team Page renders a discussion for a team to which I belong.
|
||||
Given I am enrolled in a course with a team configuration, a topic,
|
||||
and a team belonging to that topic of which I am a member
|
||||
When the team has a discussion with a thread
|
||||
And I visit the Team page for that team
|
||||
Then I should see a discussion with the correct discussion_id
|
||||
And I should see the existing thread
|
||||
And I should see controls to change the state of the discussion
|
||||
"""
|
||||
self._set_team_configuration_and_membership()
|
||||
self.verify_teams_discussion_permissions(True)
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_discussion_on_other_team_page(self, is_staff):
|
||||
"""
|
||||
Scenario: Team Page renders a team discussion for a team to which I do
|
||||
not belong.
|
||||
Given I am enrolled in a course with a team configuration, a topic,
|
||||
and a team belonging to that topic of which I am not a member
|
||||
When the team has a discussion with a thread
|
||||
And I visit the Team page for that team
|
||||
Then I should see a discussion with the correct discussion_id
|
||||
And I should see the team's thread
|
||||
And I should not see controls to change the state of the discussion
|
||||
"""
|
||||
self._set_team_configuration_and_membership(create_membership=False)
|
||||
self.setup_discussion_user(staff=is_staff)
|
||||
self.verify_teams_discussion_permissions(False)
|
||||
|
||||
@ddt.data('Moderator', 'Community TA', 'Administrator')
|
||||
def test_discussion_privileged(self, role):
|
||||
self._set_team_configuration_and_membership(create_membership=False)
|
||||
self.setup_discussion_user(role=role)
|
||||
self.verify_teams_discussion_permissions(True)
|
||||
|
||||
def assert_team_details(self, num_members, is_member=True, max_size=10):
|
||||
"""
|
||||
Verifies that user can see all the information, present on detail page according to their membership status.
|
||||
|
||||
Arguments:
|
||||
num_members (int): number of users in a team
|
||||
is_member (bool) default True: True if request user is member else False
|
||||
max_size (int): number of users a team can have
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.team_page.team_capacity_text,
|
||||
self.team_page.format_capacity_text(num_members, max_size)
|
||||
)
|
||||
self.assertEqual(self.team_page.team_location, 'Afghanistan')
|
||||
self.assertEqual(self.team_page.team_language, 'Afar')
|
||||
self.assertEqual(self.team_page.team_members, num_members)
|
||||
|
||||
if num_members > 0:
|
||||
self.assertTrue(self.team_page.team_members_present)
|
||||
else:
|
||||
self.assertFalse(self.team_page.team_members_present)
|
||||
|
||||
if is_member:
|
||||
self.assertEqual(self.team_page.team_user_membership_text, 'You are a member of this team.')
|
||||
self.assertTrue(self.team_page.team_leave_link_present)
|
||||
self.assertTrue(self.team_page.new_post_button_present)
|
||||
else:
|
||||
self.assertEqual(self.team_page.team_user_membership_text, '')
|
||||
self.assertFalse(self.team_page.team_leave_link_present)
|
||||
self.assertFalse(self.team_page.new_post_button_present)
|
||||
|
||||
def test_team_member_can_see_full_team_details(self):
|
||||
"""
|
||||
Scenario: Team member can see full info for team.
|
||||
Given I am enrolled in a course with a team configuration, a topic,
|
||||
and a team belonging to that topic of which I am a member
|
||||
When I visit the Team page for that team
|
||||
Then I should see the full team detail
|
||||
And I should see the team members
|
||||
And I should see my team membership text
|
||||
And I should see the language & country
|
||||
And I should see the Leave Team and Invite Team
|
||||
"""
|
||||
self._set_team_configuration_and_membership()
|
||||
self.team_page.visit()
|
||||
|
||||
self.assert_team_details(
|
||||
num_members=1,
|
||||
)
|
||||
|
||||
def test_other_users_can_see_limited_team_details(self):
|
||||
"""
|
||||
Scenario: Users who are not member of this team can only see limited info for this team.
|
||||
Given I am enrolled in a course with a team configuration, a topic,
|
||||
and a team belonging to that topic of which I am not a member
|
||||
When I visit the Team page for that team
|
||||
Then I should not see full team detail
|
||||
And I should see the team members
|
||||
And I should not see my team membership text
|
||||
And I should not see the Leave Team and Invite Team links
|
||||
"""
|
||||
self._set_team_configuration_and_membership(create_membership=False)
|
||||
self.team_page.visit()
|
||||
|
||||
self.assert_team_details(is_member=False, num_members=0)
|
||||
|
||||
def test_user_can_navigate_to_members_profile_page(self):
|
||||
"""
|
||||
Scenario: User can navigate to profile page via team member profile image.
|
||||
Given I am enrolled in a course with a team configuration, a topic,
|
||||
and a team belonging to that topic of which I am a member
|
||||
When I visit the Team page for that team
|
||||
Then I should see profile images for the team members
|
||||
When I click on the first profile image
|
||||
Then I should be taken to the user's profile page
|
||||
And I should see the username on profile page
|
||||
"""
|
||||
self._set_team_configuration_and_membership()
|
||||
self.team_page.visit()
|
||||
|
||||
learner_name = self.team_page.first_member_username
|
||||
|
||||
self.team_page.click_first_profile_image()
|
||||
|
||||
learner_profile_page = LearnerProfilePage(self.browser, learner_name)
|
||||
learner_profile_page.wait_for_page()
|
||||
learner_profile_page.wait_for_field('username')
|
||||
self.assertTrue(learner_profile_page.field_is_visible('username'))
|
||||
|
||||
def test_join_team(self):
|
||||
"""
|
||||
Scenario: User can join a Team if not a member already..
|
||||
|
||||
Given I am enrolled in a course with a team configuration, a topic,
|
||||
and a team belonging to that topic
|
||||
And I visit the Team page for that team
|
||||
Then I should see Join Team button
|
||||
And I should not see New Post button
|
||||
When I click on Join Team button
|
||||
Then there should be no Join Team button and no message
|
||||
And an analytics event should be emitted
|
||||
And I should see the updated information under Team Details
|
||||
And I should see New Post button
|
||||
And if I switch to "My Team", the team I have joined is displayed
|
||||
"""
|
||||
self._set_team_configuration_and_membership(create_membership=False)
|
||||
teams_page = BrowseTeamsPage(self.browser, self.course_id, self.topic)
|
||||
teams_page.visit()
|
||||
teams_page.view_first_team()
|
||||
self.assertTrue(self.team_page.join_team_button_present)
|
||||
expected_events = [
|
||||
{
|
||||
'event_type': 'edx.team.learner_added',
|
||||
'event': {
|
||||
'add_method': 'joined_from_team_view'
|
||||
}
|
||||
}
|
||||
]
|
||||
with self.assert_events_match_during(event_filter=self.only_team_events, expected_events=expected_events):
|
||||
self.team_page.click_join_team_button()
|
||||
self.assertFalse(self.team_page.join_team_button_present)
|
||||
self.assertFalse(self.team_page.join_team_message_present)
|
||||
self.assert_team_details(num_members=1, is_member=True)
|
||||
|
||||
# Verify that if one switches to "My Team" without reloading the page, the newly joined team is shown.
|
||||
self.teams_page.click_all_topics()
|
||||
self.verify_my_team_count(1)
|
||||
|
||||
def test_already_member_message(self):
|
||||
"""
|
||||
Scenario: User should see `You are already in a team` if user is a
|
||||
member of other team.
|
||||
|
||||
Given I am enrolled in a course with a team configuration, a topic,
|
||||
and a team belonging to that topic
|
||||
And I am already a member of a team
|
||||
And I visit a team other than mine
|
||||
Then I should see `You are already in a team` message
|
||||
"""
|
||||
self._set_team_configuration_and_membership(membership_team_index=0, visit_team_index=1)
|
||||
self.team_page.visit()
|
||||
self.assertEqual(self.team_page.join_team_message, 'You already belong to another team in this team set.')
|
||||
self.assert_team_details(num_members=0, is_member=False)
|
||||
|
||||
def test_team_full_message(self):
|
||||
"""
|
||||
Scenario: User should see `Team is full` message when team is full.
|
||||
|
||||
Given I am enrolled in a course with a team configuration, a topic,
|
||||
and a team belonging to that topic
|
||||
And team has no space left
|
||||
And I am not a member of any team
|
||||
And I visit the team
|
||||
Then I should see `Team is full` message
|
||||
"""
|
||||
self._set_team_configuration_and_membership(
|
||||
create_membership=True,
|
||||
max_team_size=1,
|
||||
membership_team_index=0,
|
||||
visit_team_index=0,
|
||||
another_user=True
|
||||
)
|
||||
self.team_page.visit()
|
||||
self.assertEqual(self.team_page.join_team_message, 'This team is full.')
|
||||
self.assert_team_details(num_members=1, is_member=False, max_size=1)
|
||||
|
||||
def test_leave_team(self):
|
||||
"""
|
||||
Scenario: User can leave a team.
|
||||
|
||||
Given I am enrolled in a course with a team configuration, a topic,
|
||||
and a team belonging to that topic
|
||||
And I am a member of team
|
||||
And I visit the team
|
||||
And I should not see Join Team button
|
||||
And I should see New Post button
|
||||
Then I should see Leave Team link
|
||||
When I click on Leave Team link
|
||||
Then user should be removed from team
|
||||
And an analytics event should be emitted
|
||||
And I should see Join Team button
|
||||
And I should not see New Post button
|
||||
And if I switch to "My Team", the team I have left is not displayed
|
||||
"""
|
||||
self._set_team_configuration_and_membership()
|
||||
self.team_page.visit()
|
||||
self.assertFalse(self.team_page.join_team_button_present)
|
||||
self.assert_team_details(num_members=1)
|
||||
expected_events = [
|
||||
{
|
||||
'event_type': 'edx.team.learner_removed',
|
||||
'event': {
|
||||
'remove_method': 'self_removal'
|
||||
}
|
||||
}
|
||||
]
|
||||
with self.assert_events_match_during(event_filter=self.only_team_events, expected_events=expected_events):
|
||||
# I think we're seeing the same problem that we're seeing in
|
||||
# CreateTeamTest.test_user_can_see_error_message_for_missing_data.
|
||||
# We click on the "leave team" link after it's loaded, but before
|
||||
# its JavaScript event handler is added. Adding this sleep gives
|
||||
# enough time for that event handler to bind to the link. Sorry!
|
||||
# For the story to address this anti-pattern, see TNL-5820
|
||||
time.sleep(0.5)
|
||||
self.team_page.click_leave_team_link()
|
||||
self.assert_team_details(num_members=0, is_member=False)
|
||||
self.assertTrue(self.team_page.join_team_button_present)
|
||||
|
||||
# Verify that if one switches to "My Team" without reloading the page, the old team no longer shows.
|
||||
self.teams_page.click_all_topics()
|
||||
self.verify_my_team_count(0)
|
||||
|
||||
def test_page_viewed_event(self):
|
||||
"""
|
||||
Scenario: Visiting the team profile page should fire a page viewed event.
|
||||
Given I am enrolled in a course with a team configuration and a topic
|
||||
When I visit the team profile page
|
||||
Then my browser should post a page viewed event
|
||||
"""
|
||||
self._set_team_configuration_and_membership()
|
||||
events = [{
|
||||
'event_type': 'edx.team.page_viewed',
|
||||
'event': {
|
||||
'page_name': 'single-team',
|
||||
'topic_id': self.topic['id'],
|
||||
'team_id': self.teams[0]['id']
|
||||
}
|
||||
}]
|
||||
with self.assert_events_match_during(self.only_team_events, expected_events=events):
|
||||
self.team_page.visit()
|
||||
@@ -1,55 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
End-to-end tests for admin change view.
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.admin import ChangeUserAdminPage
|
||||
from common.test.acceptance.tests.helpers import AcceptanceTest
|
||||
|
||||
|
||||
class UnicodeUsernameAdminTest(AcceptanceTest):
|
||||
"""
|
||||
Tests if it is possible to update users with unicode usernames in the admin.
|
||||
"""
|
||||
shard = 21
|
||||
|
||||
# The word below reads "Omar II", in Arabic. It also contains a space and
|
||||
# an Eastern Arabic Number another option is to use the Esperanto fake
|
||||
# language but this was used instead to test non-western letters.
|
||||
FIXTURE_USERNAME = u'عمر ٢'
|
||||
|
||||
# From the db fixture `unicode_user.json`
|
||||
FIXTURE_USER_ID = 1000
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Initializes and visits the change user admin page as a superuser.
|
||||
"""
|
||||
# Some state is constructed by the parent setUp() routine
|
||||
super(UnicodeUsernameAdminTest, self).setUp()
|
||||
|
||||
AutoAuthPage(self.browser, staff=True, superuser=True).visit()
|
||||
|
||||
# Load page objects for use by the tests
|
||||
self.page = ChangeUserAdminPage(self.browser, self.FIXTURE_USER_ID)
|
||||
|
||||
# Navigate to the index page and get testing!
|
||||
self.page.visit()
|
||||
|
||||
def test_update_first_name(self):
|
||||
"""
|
||||
As a superuser I should be able to update the first name of a user with unicode username.
|
||||
"""
|
||||
self.assertNotEqual(self.page.first_name, 'John')
|
||||
self.assertEqual(self.page.username, self.FIXTURE_USERNAME)
|
||||
|
||||
self.page.change_first_name('John')
|
||||
|
||||
self.assertFalse(self.page.is_browser_on_page(), 'Should redirect to the admin user list view on success')
|
||||
|
||||
# Visit the page again to verify changes
|
||||
self.page.visit()
|
||||
|
||||
self.assertEqual(self.page.first_name, 'John', 'The first name should be updated')
|
||||
@@ -1,220 +0,0 @@
|
||||
"""
|
||||
Acceptance tests for the Import and Export pages
|
||||
"""
|
||||
|
||||
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime
|
||||
|
||||
from common.test.acceptance.pages.studio.import_export import (
|
||||
ExportCoursePage,
|
||||
ExportLibraryPage,
|
||||
ImportCoursePage,
|
||||
ImportLibraryPage
|
||||
)
|
||||
from common.test.acceptance.pages.studio.library import LibraryEditPage
|
||||
from common.test.acceptance.pages.studio.overview import CourseOutlinePage
|
||||
from common.test.acceptance.tests.studio.base_studio_test import StudioCourseTest, StudioLibraryTest
|
||||
from openedx.core.lib.tests import attr
|
||||
|
||||
|
||||
class ExportTestMixin(object):
|
||||
"""
|
||||
Tests to run both for course and library export pages.
|
||||
"""
|
||||
def test_export(self):
|
||||
"""
|
||||
Scenario: I am able to export a course or library
|
||||
Given that I have a course or library
|
||||
And I click the download button
|
||||
The download will succeed
|
||||
And the file will be of the right MIME type.
|
||||
"""
|
||||
self.export_page.wait_for_export_click_handler()
|
||||
self.export_page.click_export()
|
||||
self.export_page.wait_for_export()
|
||||
good_status, is_tarball_mimetype = self.export_page.download_tarball()
|
||||
self.assertTrue(good_status)
|
||||
self.assertTrue(is_tarball_mimetype)
|
||||
|
||||
def test_export_timestamp(self):
|
||||
"""
|
||||
Scenario: I perform a course / library export
|
||||
On export success, the page displays a UTC timestamp previously not visible
|
||||
And if I refresh the page, the timestamp is still displayed
|
||||
"""
|
||||
self.assertFalse(self.export_page.is_timestamp_visible())
|
||||
|
||||
# Get the time when the export has started.
|
||||
# export_page timestamp is in (MM/DD/YYYY at HH:mm) so replacing (second, microsecond) to
|
||||
# keep the comparison consistent
|
||||
export_start_time = datetime.utcnow().replace(microsecond=0, second=0)
|
||||
self.export_page.wait_for_export_click_handler()
|
||||
self.export_page.click_export()
|
||||
self.export_page.wait_for_export()
|
||||
|
||||
# Get the time when the export has finished.
|
||||
# export_page timestamp is in (MM/DD/YYYY at HH:mm) so replacing (second, microsecond) to
|
||||
# keep the comparison consistent
|
||||
export_finish_time = datetime.utcnow().replace(microsecond=0, second=0)
|
||||
|
||||
export_timestamp = self.export_page.parsed_timestamp
|
||||
self.export_page.wait_for_timestamp_visible()
|
||||
|
||||
# Verify that 'export_timestamp' is between start and finish upload time
|
||||
self.assertLessEqual(
|
||||
export_start_time,
|
||||
export_timestamp,
|
||||
"Course export timestamp should be export_start_time <= export_timestamp <= export_end_time"
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
export_finish_time,
|
||||
export_timestamp,
|
||||
"Course export timestamp should be export_start_time <= export_timestamp <= export_end_time"
|
||||
)
|
||||
|
||||
self.export_page.visit()
|
||||
self.export_page.wait_for_tasks(completed=True)
|
||||
self.export_page.wait_for_timestamp_visible()
|
||||
|
||||
def test_task_list(self):
|
||||
"""
|
||||
Scenario: I should see feedback checkpoints when exporting a course or library
|
||||
Given that I am on an export page
|
||||
No task checkpoint list should be showing
|
||||
When I export the course or library
|
||||
Each task in the checklist should be marked confirmed
|
||||
And the task list should be visible
|
||||
"""
|
||||
# The task list shouldn't be visible to start.
|
||||
self.assertFalse(self.export_page.is_task_list_showing(), "Task list shown too early.")
|
||||
self.export_page.wait_for_tasks()
|
||||
self.export_page.wait_for_export_click_handler()
|
||||
self.export_page.click_export()
|
||||
self.export_page.wait_for_tasks(completed=True)
|
||||
self.assertTrue(self.export_page.is_task_list_showing(), "Task list did not display.")
|
||||
|
||||
|
||||
@attr(shard=7)
|
||||
class TestCourseExport(ExportTestMixin, StudioCourseTest):
|
||||
"""
|
||||
Export tests for courses.
|
||||
"""
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
super(TestCourseExport, self).setUp()
|
||||
self.export_page = ExportCoursePage(
|
||||
self.browser,
|
||||
self.course_info['org'], self.course_info['number'], self.course_info['run'],
|
||||
)
|
||||
self.export_page.visit()
|
||||
|
||||
def test_header(self):
|
||||
"""
|
||||
Scenario: I should see the correct text when exporting a course.
|
||||
Given that I have a course to export from
|
||||
When I visit the export page
|
||||
The correct header should be shown
|
||||
"""
|
||||
self.assertEqual(self.export_page.header_text, 'Course Export')
|
||||
|
||||
|
||||
@attr(shard=7)
|
||||
class TestLibraryExport(ExportTestMixin, StudioLibraryTest):
|
||||
"""
|
||||
Export tests for libraries.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Ensure a library exists and navigate to the library edit page.
|
||||
"""
|
||||
super(TestLibraryExport, self).setUp()
|
||||
self.export_page = ExportLibraryPage(self.browser, self.library_key)
|
||||
self.export_page.visit()
|
||||
|
||||
def test_header(self):
|
||||
"""
|
||||
Scenario: I should see the correct text when exporting a library.
|
||||
Given that I have a library to export from
|
||||
When I visit the export page
|
||||
The correct header should be shown
|
||||
"""
|
||||
self.assertEqual(self.export_page.header_text, 'Library Export')
|
||||
|
||||
|
||||
@attr(shard=7)
|
||||
class ImportTestMixin(object):
|
||||
"""
|
||||
Tests to run for both course and library import pages.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(ImportTestMixin, self).setUp()
|
||||
self.import_page = self.import_page_class(*self.page_args())
|
||||
self.landing_page = self.landing_page_class(*self.page_args())
|
||||
self.import_page.visit()
|
||||
|
||||
@abstractmethod
|
||||
def page_args(self):
|
||||
"""
|
||||
Generates the args for initializing a page object.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
@attr(shard=7)
|
||||
class TestEntranceExamCourseImport(ImportTestMixin, StudioCourseTest):
|
||||
"""
|
||||
Tests the Course import page
|
||||
"""
|
||||
tarball_name = 'entrance_exam_course.2015.tar.gz'
|
||||
bad_tarball_name = 'bad_course.tar.gz'
|
||||
import_page_class = ImportCoursePage
|
||||
landing_page_class = CourseOutlinePage
|
||||
|
||||
def page_args(self):
|
||||
return [self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']]
|
||||
|
||||
|
||||
@attr(shard=7)
|
||||
class TestCourseImport(ImportTestMixin, StudioCourseTest):
|
||||
"""
|
||||
Tests the Course import page
|
||||
"""
|
||||
tarball_name = '2015.lzdwNM.tar.gz'
|
||||
bad_tarball_name = 'bad_course.tar.gz'
|
||||
import_page_class = ImportCoursePage
|
||||
landing_page_class = CourseOutlinePage
|
||||
|
||||
def page_args(self):
|
||||
return [self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']]
|
||||
|
||||
def test_header(self):
|
||||
"""
|
||||
Scenario: I should see the correct text when importing a course.
|
||||
Given that I have a course to import to
|
||||
When I visit the import page
|
||||
The correct header should be shown
|
||||
"""
|
||||
self.assertEqual(self.import_page.header_text, 'Course Import')
|
||||
|
||||
|
||||
@attr(shard=7)
|
||||
class TestLibraryImport(ImportTestMixin, StudioLibraryTest):
|
||||
"""
|
||||
Tests the Library import page
|
||||
"""
|
||||
tarball_name = 'library.HhJfPD.tar.gz'
|
||||
bad_tarball_name = 'bad_library.tar.gz'
|
||||
import_page_class = ImportLibraryPage
|
||||
landing_page_class = LibraryEditPage
|
||||
|
||||
def page_args(self):
|
||||
return [self.browser, self.library_key]
|
||||
|
||||
def test_header(self):
|
||||
"""
|
||||
Scenario: I should see the correct text when importing a library.
|
||||
Given that I have a library to import to
|
||||
When I visit the import page
|
||||
The correct header should be shown
|
||||
"""
|
||||
self.assertEqual(self.import_page.header_text, 'Library Import')
|
||||
@@ -1,208 +0,0 @@
|
||||
"""
|
||||
Acceptance tests for Studio related to the acid xblock.
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.studio.overview import CourseOutlinePage
|
||||
from common.test.acceptance.pages.xblock.acid import AcidView
|
||||
from common.test.acceptance.tests.helpers import AcceptanceTest
|
||||
|
||||
|
||||
class XBlockAcidBase(AcceptanceTest):
|
||||
"""
|
||||
Base class for tests that verify that XBlock integration is working correctly
|
||||
"""
|
||||
shard = 21
|
||||
__test__ = False
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a unique identifier for the course used in this test.
|
||||
"""
|
||||
# Ensure that the superclass sets up
|
||||
super(XBlockAcidBase, self).setUp()
|
||||
|
||||
# Define a unique course identifier
|
||||
self.course_info = {
|
||||
'org': 'test_org',
|
||||
'number': 'course_' + self.unique_id[:5],
|
||||
'run': 'test_' + self.unique_id,
|
||||
'display_name': 'Test Course ' + self.unique_id
|
||||
}
|
||||
|
||||
self.outline = CourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
self.course_id = '{org}.{number}.{run}'.format(**self.course_info)
|
||||
|
||||
self.setup_fixtures()
|
||||
|
||||
self.auth_page = AutoAuthPage(
|
||||
self.browser,
|
||||
staff=False,
|
||||
username=self.user.get('username'),
|
||||
email=self.user.get('email'),
|
||||
password=self.user.get('password')
|
||||
)
|
||||
self.auth_page.visit()
|
||||
|
||||
def validate_acid_block_preview(self, acid_block):
|
||||
"""
|
||||
Validate the Acid Block's preview
|
||||
"""
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.resource_url_passed)
|
||||
self.assertTrue(acid_block.scope_passed('user_state'))
|
||||
self.assertTrue(acid_block.scope_passed('user_state_summary'))
|
||||
self.assertTrue(acid_block.scope_passed('preferences'))
|
||||
self.assertTrue(acid_block.scope_passed('user_info'))
|
||||
|
||||
def test_acid_block_preview(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in studio preview
|
||||
"""
|
||||
|
||||
self.outline.visit()
|
||||
subsection = self.outline.section('Test Section').subsection('Test Subsection')
|
||||
unit = subsection.expand_subsection().unit('Test Unit').go_to()
|
||||
|
||||
acid_block = AcidView(self.browser, unit.xblocks[0].preview_selector)
|
||||
self.validate_acid_block_preview(acid_block)
|
||||
|
||||
def test_acid_block_editor(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in studio editor
|
||||
"""
|
||||
|
||||
self.outline.visit()
|
||||
subsection = self.outline.section('Test Section').subsection('Test Subsection')
|
||||
unit = subsection.expand_subsection().unit('Test Unit').go_to()
|
||||
|
||||
acid_block = AcidView(self.browser, unit.xblocks[0].edit().editor_selector)
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.resource_url_passed)
|
||||
|
||||
|
||||
class XBlockAcidNoChildTest(XBlockAcidBase):
|
||||
"""
|
||||
Tests of an AcidBlock with no children
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
def setup_fixtures(self):
|
||||
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('acid', 'Acid Block')
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
self.user = course_fix.user
|
||||
|
||||
|
||||
class XBlockAcidParentBase(XBlockAcidBase):
|
||||
"""
|
||||
Base class for tests that verify that parent XBlock integration is working correctly
|
||||
"""
|
||||
__test__ = False
|
||||
|
||||
def validate_acid_block_preview(self, acid_block):
|
||||
super(XBlockAcidParentBase, self).validate_acid_block_preview(acid_block)
|
||||
self.assertTrue(acid_block.child_tests_passed)
|
||||
|
||||
def test_acid_block_preview(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in studio preview
|
||||
"""
|
||||
|
||||
self.outline.visit()
|
||||
subsection = self.outline.section('Test Section').subsection('Test Subsection')
|
||||
unit = subsection.expand_subsection().unit('Test Unit').go_to()
|
||||
container = unit.xblocks[0].go_to_container()
|
||||
|
||||
acid_block = AcidView(self.browser, container.xblocks[0].preview_selector)
|
||||
self.validate_acid_block_preview(acid_block)
|
||||
|
||||
|
||||
class XBlockAcidEmptyParentTest(XBlockAcidParentBase):
|
||||
"""
|
||||
Tests of an AcidBlock with children
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
def setup_fixtures(self):
|
||||
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
self.user = course_fix.user
|
||||
|
||||
|
||||
class XBlockAcidChildTest(XBlockAcidParentBase):
|
||||
"""
|
||||
Tests of an AcidBlock with children
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
def setup_fixtures(self):
|
||||
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('acid_parent', 'Acid Parent Block').add_children(
|
||||
XBlockFixtureDesc('acid', 'First Acid Child', metadata={'name': 'first'}),
|
||||
XBlockFixtureDesc('acid', 'Second Acid Child', metadata={'name': 'second'}),
|
||||
XBlockFixtureDesc('html', 'Html Child', data="<html>Contents</html>"),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
self.user = course_fix.user
|
||||
|
||||
def test_acid_block_preview(self):
|
||||
super(XBlockAcidChildTest, self).test_acid_block_preview()
|
||||
|
||||
def test_acid_block_editor(self):
|
||||
super(XBlockAcidChildTest, self).test_acid_block_editor()
|
||||
@@ -1,250 +0,0 @@
|
||||
"""
|
||||
Acceptance tests for Studio related to the asset index page.
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
|
||||
from common.test.acceptance.pages.studio.asset_index import UPLOAD_FILE_DIR, AssetIndexPageStudioFrontend
|
||||
from common.test.acceptance.tests.studio.base_studio_test import StudioCourseTest
|
||||
|
||||
|
||||
class AssetIndexTestStudioFrontend(StudioCourseTest):
|
||||
"""Tests for the Asset index page."""
|
||||
shard = 21
|
||||
|
||||
def setUp(self, is_staff=False): # pylint: disable=arguments-differ
|
||||
super(AssetIndexTestStudioFrontend, self).setUp()
|
||||
self.asset_page = AssetIndexPageStudioFrontend(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""Populate the children of the test course fixture."""
|
||||
self.course_fixture.add_asset(['image.jpg', 'textbook.pdf'])
|
||||
|
||||
def test_page_with_assets_elements_load(self):
|
||||
"""Make sure all elements are on page for a course with assets."""
|
||||
self.asset_page.visit()
|
||||
assert self.assert_studio_frontend_container_exists()
|
||||
assert self.assert_table_exists()
|
||||
assert self.assert_type_filter_exists()
|
||||
assert self.assert_upload_element_exists()
|
||||
assert self.assert_sortable_table_heading_elements_exist()
|
||||
assert self.assert_status_element_exists()
|
||||
assert self.assert_pagination_element_exists()
|
||||
assert self.assert_search_element_exists()
|
||||
|
||||
def assert_page_without_filter_results_elements_load(self):
|
||||
"""Make sure correct elements are on page for a filter with no results."""
|
||||
assert self.assert_studio_frontend_container_exists()
|
||||
assert not self.assert_table_exists()
|
||||
assert not self.assert_sortable_table_heading_elements_exist()
|
||||
assert not self.assert_pagination_element_exists()
|
||||
|
||||
assert self.assert_search_element_exists()
|
||||
assert self.assert_status_element_exists()
|
||||
assert self.assert_type_filter_exists()
|
||||
assert self.assert_upload_element_exists()
|
||||
assert self.assert_no_results_headings_exist()
|
||||
assert self.assert_clear_filters_button_exists()
|
||||
|
||||
def assert_studio_frontend_container_exists(self):
|
||||
return self.asset_page.is_studio_frontend_container_on_page()
|
||||
|
||||
def assert_table_exists(self):
|
||||
"""Make sure table is on the page."""
|
||||
return self.asset_page.is_table_element_on_page()
|
||||
|
||||
def assert_type_filter_exists(self):
|
||||
"""Make sure type filter is on the page."""
|
||||
return self.asset_page.is_filter_element_on_page() is True
|
||||
|
||||
def assert_upload_element_exists(self):
|
||||
"""Make sure upload dropzone is on the page."""
|
||||
return self.asset_page.is_upload_element_on_page()
|
||||
|
||||
def assert_sortable_table_heading_elements_exist(self):
|
||||
"""
|
||||
Make sure the sortable table buttons are on the page and there arethree of them."""
|
||||
return self.asset_page.number_of_sortable_buttons_in_table_heading == 3
|
||||
|
||||
def assert_status_element_exists(self):
|
||||
"""Make sure status alert is on the page but not visible."""
|
||||
return self.asset_page.is_status_alert_element_on_page()
|
||||
|
||||
def assert_pagination_element_exists(self):
|
||||
"""Make sure pagination element is on the page."""
|
||||
return self.asset_page.is_pagination_element_on_page() is True
|
||||
|
||||
def assert_search_element_exists(self):
|
||||
"""Make sure search element is on the page."""
|
||||
return self.asset_page.is_search_element_on_page() is True
|
||||
|
||||
def assert_no_results_headings_exist(self):
|
||||
"""Make sure headings with text for no results is on the page."""
|
||||
return self.asset_page.are_no_results_headings_on_page()
|
||||
|
||||
def assert_clear_filters_button_exists(self):
|
||||
"""Make sure the clear filters button is on the page."""
|
||||
return self.asset_page.is_no_results_clear_filter_button_on_page()
|
||||
|
||||
def test_clicking_filter_with_results(self):
|
||||
"""Make sure clicking the Images filter that has results and performs the filtering correctly."""
|
||||
self.asset_page.visit()
|
||||
all_results = self.asset_page.number_of_asset_files
|
||||
# select Images
|
||||
assert self.asset_page.select_type_filter(3)
|
||||
filtered_results = self.asset_page.number_of_asset_files
|
||||
assert all_results > filtered_results
|
||||
assets_file_types = self.asset_page.asset_files_types
|
||||
for file_type in assets_file_types:
|
||||
assert 'image' in file_type
|
||||
|
||||
def test_clicking_filter_without_results(self):
|
||||
"""
|
||||
Make sure clicking a type filter that has no results performs the filtering correctly, updates the page view to
|
||||
display the no results view, and displays the correct elements.
|
||||
"""
|
||||
self.asset_page.visit()
|
||||
all_results = self.asset_page.number_of_asset_files
|
||||
# select Audio
|
||||
assert self.asset_page.select_type_filter(0)
|
||||
filtered_results = self.asset_page.number_of_asset_files
|
||||
assert all_results > filtered_results
|
||||
assert filtered_results == 0
|
||||
self.assert_page_without_filter_results_elements_load()
|
||||
|
||||
def test_clicking_clear_filter(self):
|
||||
"""Make sure clicking the 'Clear filter' button clears the checkbox and returns results."""
|
||||
self.asset_page.visit()
|
||||
all_results = self.asset_page.number_of_asset_files
|
||||
# select Audio
|
||||
assert self.asset_page.select_type_filter(0)
|
||||
assert self.asset_page.click_clear_filters_button()
|
||||
new_results = self.asset_page.number_of_asset_files
|
||||
assert new_results == all_results
|
||||
self.test_page_with_assets_elements_load()
|
||||
|
||||
def test_lock(self):
|
||||
"""Make sure clicking the lock button toggles correctly."""
|
||||
self.asset_page.visit()
|
||||
# Verify that a file can be locked
|
||||
self.asset_page.set_asset_lock()
|
||||
# Get the list of locked assets, there should be one
|
||||
locked_assets = self.asset_page.asset_lock_buttons(locked_only=True)
|
||||
self.assertEqual(len(locked_assets), 1)
|
||||
|
||||
# Confirm that there are 2 assets, with the first
|
||||
# locked and the second unlocked.
|
||||
all_assets = self.asset_page.asset_lock_buttons(locked_only=False)
|
||||
self.assertEqual(len(all_assets), 2)
|
||||
self.assertTrue('fa-lock' in all_assets[0].get_attribute('class'))
|
||||
self.assertTrue('fa-unlock' in all_assets[1].get_attribute('class'))
|
||||
|
||||
def test_delete_and_upload(self):
|
||||
"""
|
||||
Upload specific files to page.
|
||||
Start by deleting all files, to ensure starting on a blank slate.
|
||||
"""
|
||||
self.asset_page.visit()
|
||||
self.asset_page.delete_all_assets()
|
||||
file_names = [u'file-0.png', u'file-13.pdf', u'file-26.js', u'file-39.txt']
|
||||
# Upload the files
|
||||
self.asset_page.upload_new_files(file_names)
|
||||
# Assert that the files have been uploaded.
|
||||
all_assets = self.asset_page.number_of_asset_files
|
||||
self.assertEqual(all_assets, 4)
|
||||
self.assertEqual(file_names.sort(), self.asset_page.asset_files_names.sort())
|
||||
|
||||
def test_display_name_sort(self):
|
||||
"""Make sure clicking the display name sort button sorts the files."""
|
||||
self.asset_page.visit()
|
||||
# the default sort is on 'Date Added', so sort on 'Name' to start
|
||||
# with a fresh state
|
||||
self.asset_page.click_sort_button('Name')
|
||||
before_sort_file_names = self.asset_page.asset_files_names
|
||||
sorted_file_names = sorted(before_sort_file_names)
|
||||
|
||||
assert self.asset_page.click_sort_button('Name')
|
||||
after_sort_file_names = self.asset_page.asset_files_names
|
||||
assert before_sort_file_names != after_sort_file_names
|
||||
assert sorted_file_names == after_sort_file_names
|
||||
|
||||
assert self.asset_page.click_sort_button('Name')
|
||||
assert self.asset_page.asset_files_names == before_sort_file_names
|
||||
|
||||
|
||||
class AssetIndexTestStudioFrontendPagination(StudioCourseTest):
|
||||
"""Pagination tests for the Asset index page."""
|
||||
shard = 23
|
||||
|
||||
def setUp(self, is_staff=False): # pylint: disable=arguments-differ
|
||||
super(AssetIndexTestStudioFrontendPagination, self).setUp()
|
||||
self.asset_page = AssetIndexPageStudioFrontend(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""Populate the children of the test course fixture and upload 49 files."""
|
||||
files = []
|
||||
|
||||
for file_name in os.listdir(UPLOAD_FILE_DIR):
|
||||
file_path = 'studio-uploads/' + file_name
|
||||
files.append(file_path)
|
||||
course_fixture.add_asset(files)
|
||||
|
||||
def assert_correct_number_of_buttons(self, count):
|
||||
"""Make sure the correct number of buttons are on the page; includes previous and next. """
|
||||
assert self.asset_page.number_of_pagination_buttons == count
|
||||
|
||||
def assert_correct_direction_buttons(self):
|
||||
"""Make sure the previous and next pagination buttons are on the page."""
|
||||
assert self.asset_page.is_previous_button_on_page
|
||||
assert self.asset_page.is_next_button_on_page
|
||||
|
||||
def test_pagination_exists(self):
|
||||
"""Make sure the pagination elements are on the page."""
|
||||
self.asset_page.visit()
|
||||
self.assert_correct_number_of_buttons(4)
|
||||
self.assert_correct_direction_buttons()
|
||||
|
||||
def test_pagination_page_click(self):
|
||||
"""Make clicking the second page button displays the second page of files."""
|
||||
self.asset_page.visit()
|
||||
|
||||
first_page_file_names = self.asset_page.asset_files_names
|
||||
assert self.asset_page.click_pagination_page_button(2)
|
||||
assert self.asset_page.is_selected_page(2)
|
||||
assert self.asset_page.number_of_asset_files == 1
|
||||
second_page_file_names = self.asset_page.asset_files_names
|
||||
|
||||
assert first_page_file_names != second_page_file_names
|
||||
|
||||
def test_pagination_next_and_previous_click(self):
|
||||
"""
|
||||
Make sure clicking the next button displays the next page of files and
|
||||
clicking the previous button displays the previous page of files.
|
||||
"""
|
||||
self.asset_page.visit()
|
||||
|
||||
first_page_file_names = self.asset_page.asset_files_names
|
||||
assert self.asset_page.click_pagination_next_button()
|
||||
assert self.asset_page.is_selected_page(2)
|
||||
assert self.asset_page.number_of_asset_files == 1
|
||||
next_page_file_names = self.asset_page.asset_files_names
|
||||
|
||||
assert first_page_file_names != next_page_file_names
|
||||
|
||||
assert self.asset_page.click_pagination_previous_button()
|
||||
assert self.asset_page.is_selected_page(1)
|
||||
assert self.asset_page.number_of_asset_files == 50
|
||||
previous_page_file_names = self.asset_page.asset_files_names
|
||||
|
||||
assert first_page_file_names == previous_page_file_names
|
||||
@@ -1,107 +0,0 @@
|
||||
"""
|
||||
Acceptance tests that ensure components with bad content do not break page.
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.studio.utils import verify_ordering
|
||||
|
||||
from .base_studio_test import ContainerBase
|
||||
|
||||
|
||||
class BadComponentTest(ContainerBase):
|
||||
"""
|
||||
Tests that components with bad content do not break the Unit page.
|
||||
"""
|
||||
shard = 21
|
||||
__test__ = False
|
||||
|
||||
def get_bad_html_content(self):
|
||||
"""
|
||||
Return the "bad" HTML content that has been problematic for Studio.
|
||||
"""
|
||||
pass
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Sets up a course structure with a unit and a HTML component with bad data and a properly constructed problem.
|
||||
"""
|
||||
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('html', 'Unit HTML', data=self.get_bad_html_content()),
|
||||
XBlockFixtureDesc('problem', 'Unit Problem', data='<problem></problem>')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_html_comp_visible(self):
|
||||
"""
|
||||
Tests that bad HTML data within an HTML component doesn't prevent Studio from
|
||||
displaying the components on the unit page.
|
||||
"""
|
||||
unit = self.go_to_unit_page()
|
||||
verify_ordering(self, unit, [{"": ["Unit HTML", "Unit Problem"]}])
|
||||
|
||||
|
||||
class CopiedFromLmsBadContentTest(BadComponentTest):
|
||||
"""
|
||||
Tests that components with HTML copied from the LMS (LmsRuntime) do not break the Unit page.
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
def get_bad_html_content(self):
|
||||
"""
|
||||
Return the "bad" HTML content that has been problematic for Studio.
|
||||
"""
|
||||
return """
|
||||
<div class="xblock xblock-student_view xmodule_display xmodule_HtmlBlock xblock-initialized"
|
||||
data-runtime-class="LmsRuntime" data-init="XBlockToXModuleShim" data-block-type="html"
|
||||
data-runtime-version="1" data-type="HTMLModule" data-course-id="GeorgetownX/HUMW-421-01"
|
||||
data-request-token="thisIsNotARealRequestToken"
|
||||
data-usage-id="i4x:;_;_GeorgetownX;_HUMW-421-01;_html;_3010cbbecaa1484da6cf8ba01362346a">
|
||||
<p>Copied from LMS HTML component</p></div>
|
||||
"""
|
||||
|
||||
|
||||
class CopiedFromStudioBadContentTest(BadComponentTest):
|
||||
"""
|
||||
Tests that components with HTML copied from the Studio (containing "ui-sortable" class) do not break the Unit page.
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
def get_bad_html_content(self):
|
||||
"""
|
||||
Return the "bad" HTML content that has been problematic for Studio.
|
||||
"""
|
||||
return """
|
||||
<ol class="components ui-sortable">
|
||||
<li class="component" data-locator="i4x://Wellesley_College/100/html/6390f1fd3fe640d49580b8415fe1330b"
|
||||
data-course-key="Wellesley_College/100/2014_Summer">
|
||||
<div class="xblock xblock-student_view xmodule_display xmodule_HtmlBlock xblock-initialized"
|
||||
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
|
||||
data-request-token="thisIsNotARealRequestToken"
|
||||
data-usage-id="i4x://Wellesley_College/100/html/6390f1fd3fe640d49580b8415fe1330b"
|
||||
data-type="HTMLModule" data-block-type="html">
|
||||
<h2>VOICE COMPARISON </h2>
|
||||
<p>You can access the experimental <strong >Voice Comparison</strong> tool at the link below.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
"""
|
||||
|
||||
|
||||
class JSErrorBadContentTest(BadComponentTest):
|
||||
"""
|
||||
Tests that components that throw JS errors do not break the Unit page.
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
def get_bad_html_content(self):
|
||||
"""
|
||||
Return the "bad" HTML content that has been problematic for Studio.
|
||||
"""
|
||||
return "<script>var doesNotExist = BadGlobal.foo;</script>"
|
||||
@@ -1,190 +0,0 @@
|
||||
"""
|
||||
Acceptance tests for adding components in Studio.
|
||||
"""
|
||||
|
||||
|
||||
import ddt
|
||||
|
||||
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.studio.container import ContainerPage
|
||||
from common.test.acceptance.pages.studio.settings_advanced import AdvancedSettingsPage
|
||||
from common.test.acceptance.pages.studio.utils import add_component, add_components
|
||||
from common.test.acceptance.tests.studio.base_studio_test import ContainerBase
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class AdvancedProblemComponentTest(ContainerBase):
|
||||
"""
|
||||
Feature: CMS.Component Adding
|
||||
As a course author, I want to be able to add a wide variety of components
|
||||
"""
|
||||
shard = 22
|
||||
|
||||
def setUp(self, is_staff=True):
|
||||
"""
|
||||
Create a course with a section, subsection, and unit to which to add the component.
|
||||
"""
|
||||
super(AdvancedProblemComponentTest, self).setUp(is_staff=is_staff)
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
course_fixture.add_advanced_settings(
|
||||
{u"advanced_modules": {"value": ["split_test"]}}
|
||||
)
|
||||
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
'Blank Advanced Problem',
|
||||
'Circuit Schematic Builder',
|
||||
'Custom Python-Evaluated Input',
|
||||
'Drag and Drop',
|
||||
'Image Mapped Input',
|
||||
'Math Expression Input',
|
||||
'Problem with Adaptive Hint',
|
||||
)
|
||||
def test_add_advanced_problem(self, component):
|
||||
"""
|
||||
Scenario Outline: I can add Advanced Problem components
|
||||
Given I am in Studio editing a new unit
|
||||
When I add a "<Component>" "Advanced Problem" component
|
||||
Then I see a "<Component>" Problem component
|
||||
|
||||
Examples:
|
||||
| Component |
|
||||
| Blank Advanced Problem |
|
||||
| Circuit Schematic Builder |
|
||||
| Custom Python-Evaluated Input |
|
||||
| Drag and Drop |
|
||||
| Image Mapped Input |
|
||||
| Math Expression Input |
|
||||
| Problem with Adaptive Hint |
|
||||
"""
|
||||
self.go_to_unit_page()
|
||||
page = ContainerPage(self.browser, None)
|
||||
add_component(page, 'problem', component, is_advanced_problem=True)
|
||||
problem = page.xblocks[1]
|
||||
self.assertEqual(problem.name, component)
|
||||
|
||||
|
||||
class ComponentTest(ContainerBase):
|
||||
"""
|
||||
Test class to add different components.
|
||||
(Not the advanced components)
|
||||
"""
|
||||
shard = 22
|
||||
|
||||
def setUp(self, is_staff=True):
|
||||
"""
|
||||
Create a course with a section, subsection, and unit to which to add the component.
|
||||
"""
|
||||
super(ComponentTest, self).setUp(is_staff=is_staff)
|
||||
self.advanced_settings = AdvancedSettingsPage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
course_fixture.add_advanced_settings(
|
||||
{u"advanced_modules": {"value": ["split_test"]}}
|
||||
)
|
||||
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_add_html_component(self):
|
||||
"""
|
||||
Scenario: I can add HTML components
|
||||
Given I am in Studio editing a new unit
|
||||
When I add this type of HTML component:
|
||||
| Component |
|
||||
| Text |
|
||||
| Announcement |
|
||||
| Zooming Image Tool |
|
||||
| Raw HTML |
|
||||
Then I see HTML components in this order:
|
||||
| Component |
|
||||
| Text |
|
||||
| Announcement |
|
||||
| Zooming Image Tool |
|
||||
| Raw HTML |
|
||||
"""
|
||||
# Components to be added
|
||||
components = ['Text', 'Announcement', 'Zooming Image Tool', 'Raw HTML']
|
||||
self.go_to_unit_page()
|
||||
container_page = ContainerPage(self.browser, None)
|
||||
# Add components
|
||||
add_components(container_page, 'html', components)
|
||||
problems = [x_block.name for x_block in container_page.xblocks[1:]]
|
||||
# Assert that components appear in same order as added.
|
||||
self.assertEqual(problems, components)
|
||||
|
||||
def test_add_latex_html_component(self):
|
||||
"""
|
||||
Scenario: I can add Latex HTML components
|
||||
Given I am in Studio editing a new unit
|
||||
Given I have enabled latex compiler
|
||||
When I add this type of HTML component:
|
||||
| Component |
|
||||
| E-text Written in LaTeX |
|
||||
Then I see HTML components in this order:
|
||||
| Component |
|
||||
| E-text Written in LaTeX |
|
||||
"""
|
||||
# Latex component
|
||||
component = 'E-text Written in LaTeX'
|
||||
# Visit advanced settings page and enable latex compiler.
|
||||
self.advanced_settings.visit()
|
||||
self.advanced_settings.set('Enable LaTeX Compiler', 'True')
|
||||
self.go_to_unit_page()
|
||||
container_page = ContainerPage(self.browser, None)
|
||||
# Add latex component
|
||||
add_component(container_page, 'html', component, is_advanced_problem=False)
|
||||
problem = container_page.xblocks[1]
|
||||
# Asset that component has been added.
|
||||
self.assertEqual(problem.name, component)
|
||||
|
||||
def test_common_problem_component(self):
|
||||
"""
|
||||
Scenario: I can add Common Problem components
|
||||
Given I am in Studio editing a new unit
|
||||
When I add this type of Problem component:
|
||||
| Component |`
|
||||
| Blank Common Problem |
|
||||
| Checkboxes |
|
||||
| Dropdown |
|
||||
| Multiple Choice |
|
||||
| Numerical Input |
|
||||
| Text Input |
|
||||
Then I see Problem components in this order:
|
||||
| Component |
|
||||
| Blank Common Problem |
|
||||
| Checkboxes |
|
||||
| Dropdown |
|
||||
| Multiple Choice |
|
||||
| Numerical Input |
|
||||
| Text Input |
|
||||
"""
|
||||
# Components to be added.
|
||||
components = ['Blank Common Problem', 'Checkboxes', 'Dropdown',
|
||||
'Multiple Choice', 'Numerical Input', 'Text Input']
|
||||
|
||||
self.go_to_unit_page()
|
||||
container_page = ContainerPage(self.browser, None)
|
||||
# Add components
|
||||
add_components(container_page, 'problem', components)
|
||||
problems = [x_block.name for x_block in container_page.xblocks[1:]]
|
||||
# Assert that components appear in the same order as added.
|
||||
self.assertEqual(problems, components)
|
||||
@@ -1,1481 +0,0 @@
|
||||
"""
|
||||
Acceptance tests for Studio related to the container page.
|
||||
The container page is used both for displaying units, and
|
||||
for displaying containers within units.
|
||||
"""
|
||||
|
||||
|
||||
import datetime
|
||||
|
||||
import ddt
|
||||
import six
|
||||
|
||||
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.pages.lms.create_mode import ModeCreationPage
|
||||
from common.test.acceptance.pages.lms.staff_view import StaffCoursewarePage
|
||||
from common.test.acceptance.pages.studio.container import ContainerPage
|
||||
from common.test.acceptance.pages.studio.html_component_editor import HtmlXBlockEditorView
|
||||
from common.test.acceptance.pages.studio.move_xblock import MoveModalView
|
||||
from common.test.acceptance.pages.studio.utils import add_discussion
|
||||
from common.test.acceptance.pages.studio.xblock_editor import XBlockEditorView, XBlockVisibilityEditorView
|
||||
from common.test.acceptance.tests.helpers import create_user_partition_json
|
||||
from openedx.core.lib.tests import attr
|
||||
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID, MINIMUM_STATIC_PARTITION_ID, Group
|
||||
|
||||
from .base_studio_test import ContainerBase
|
||||
|
||||
|
||||
class NestedVerticalTest(ContainerBase):
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Sets up a course structure with nested verticals.
|
||||
"""
|
||||
self.container_title = ""
|
||||
self.group_a = "Group A"
|
||||
self.group_b = "Group B"
|
||||
self.group_empty = "Group Empty"
|
||||
self.group_a_item_1 = "Group A Item 1"
|
||||
self.group_a_item_2 = "Group A Item 2"
|
||||
self.group_b_item_1 = "Group B Item 1"
|
||||
self.group_b_item_2 = "Group B Item 2"
|
||||
|
||||
self.group_a_handle = 0
|
||||
self.group_a_item_1_handle = 1
|
||||
self.group_a_item_2_handle = 2
|
||||
self.group_empty_handle = 3
|
||||
self.group_b_handle = 4
|
||||
self.group_b_item_1_handle = 5
|
||||
self.group_b_item_2_handle = 6
|
||||
|
||||
self.group_a_item_1_action_index = 0
|
||||
self.group_a_item_2_action_index = 1
|
||||
|
||||
self.duplicate_label = u"Duplicate of '{0}'"
|
||||
self.discussion_label = "Discussion"
|
||||
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Container').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Group A').add_children(
|
||||
XBlockFixtureDesc('html', self.group_a_item_1),
|
||||
XBlockFixtureDesc('html', self.group_a_item_2)
|
||||
),
|
||||
XBlockFixtureDesc('vertical', 'Group Empty'),
|
||||
XBlockFixtureDesc('vertical', 'Group B').add_children(
|
||||
XBlockFixtureDesc('html', self.group_b_item_1),
|
||||
XBlockFixtureDesc('html', self.group_b_item_2)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@attr(shard=1)
|
||||
class AddComponentTest(NestedVerticalTest):
|
||||
"""
|
||||
Tests of adding a component to the container page.
|
||||
"""
|
||||
|
||||
def add_and_verify(self, menu_index, expected_ordering):
|
||||
self.do_action_and_verify(
|
||||
lambda container: add_discussion(container, menu_index),
|
||||
expected_ordering
|
||||
)
|
||||
|
||||
def test_add_component_in_group(self):
|
||||
group_b_menu = 2
|
||||
|
||||
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
|
||||
{self.group_a: [self.group_a_item_1, self.group_a_item_2]},
|
||||
{self.group_b: [self.group_b_item_1, self.group_b_item_2, self.discussion_label]},
|
||||
{self.group_empty: []}]
|
||||
self.add_and_verify(group_b_menu, expected_ordering)
|
||||
|
||||
def test_add_component_in_empty_group(self):
|
||||
group_empty_menu = 1
|
||||
|
||||
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
|
||||
{self.group_a: [self.group_a_item_1, self.group_a_item_2]},
|
||||
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
|
||||
{self.group_empty: [self.discussion_label]}]
|
||||
self.add_and_verify(group_empty_menu, expected_ordering)
|
||||
|
||||
def test_add_component_in_container(self):
|
||||
container_menu = 3
|
||||
|
||||
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b, self.discussion_label]},
|
||||
{self.group_a: [self.group_a_item_1, self.group_a_item_2]},
|
||||
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
|
||||
{self.group_empty: []}]
|
||||
self.add_and_verify(container_menu, expected_ordering)
|
||||
|
||||
|
||||
@attr(shard=1)
|
||||
class DuplicateComponentTest(NestedVerticalTest):
|
||||
"""
|
||||
Tests of duplicating a component on the container page.
|
||||
"""
|
||||
|
||||
def duplicate_and_verify(self, source_index, expected_ordering):
|
||||
self.do_action_and_verify(
|
||||
lambda container: container.duplicate(source_index),
|
||||
expected_ordering
|
||||
)
|
||||
|
||||
def test_duplicate_first_in_group(self):
|
||||
duplicate_label = self.duplicate_label.format(self.group_a_item_1)
|
||||
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
|
||||
{self.group_a: [self.group_a_item_1, duplicate_label, self.group_a_item_2]},
|
||||
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
|
||||
{self.group_empty: []}]
|
||||
self.duplicate_and_verify(self.group_a_item_1_action_index, expected_ordering)
|
||||
|
||||
def test_duplicate_second_in_group(self):
|
||||
duplicate_label = self.duplicate_label.format(self.group_a_item_2)
|
||||
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
|
||||
{self.group_a: [self.group_a_item_1, self.group_a_item_2, duplicate_label]},
|
||||
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
|
||||
{self.group_empty: []}]
|
||||
self.duplicate_and_verify(self.group_a_item_2_action_index, expected_ordering)
|
||||
|
||||
def test_duplicate_the_duplicate(self):
|
||||
first_duplicate_label = self.duplicate_label.format(self.group_a_item_1)
|
||||
second_duplicate_label = self.duplicate_label.format(first_duplicate_label)
|
||||
|
||||
expected_ordering = [
|
||||
{self.container_title: [self.group_a, self.group_empty, self.group_b]},
|
||||
{self.group_a: [self.group_a_item_1, first_duplicate_label, second_duplicate_label, self.group_a_item_2]},
|
||||
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
|
||||
{self.group_empty: []}
|
||||
]
|
||||
|
||||
def duplicate_twice(container):
|
||||
container.duplicate(self.group_a_item_1_action_index)
|
||||
container.duplicate(self.group_a_item_1_action_index + 1)
|
||||
|
||||
self.do_action_and_verify(duplicate_twice, expected_ordering)
|
||||
|
||||
|
||||
@attr(shard=1)
|
||||
class DeleteComponentTest(NestedVerticalTest):
|
||||
"""
|
||||
Tests of deleting a component from the container page.
|
||||
"""
|
||||
|
||||
def delete_and_verify(self, source_index, expected_ordering):
|
||||
self.do_action_and_verify(
|
||||
lambda container: container.delete(source_index),
|
||||
expected_ordering
|
||||
)
|
||||
|
||||
def test_delete_first_in_group(self):
|
||||
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
|
||||
{self.group_a: [self.group_a_item_2]},
|
||||
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
|
||||
{self.group_empty: []}]
|
||||
|
||||
# Group A itself has a delete icon now, so item_1 is index 1 instead of 0.
|
||||
group_a_item_1_delete_index = 1
|
||||
self.delete_and_verify(group_a_item_1_delete_index, expected_ordering)
|
||||
|
||||
|
||||
@attr(shard=19)
|
||||
class EditContainerTest(NestedVerticalTest):
|
||||
"""
|
||||
Tests of editing a container.
|
||||
"""
|
||||
|
||||
def modify_display_name_and_verify(self, component):
|
||||
"""
|
||||
Helper method for changing a display name.
|
||||
"""
|
||||
modified_name = 'modified'
|
||||
self.assertNotEqual(component.name, modified_name)
|
||||
component.edit()
|
||||
component_editor = XBlockEditorView(self.browser, component.locator)
|
||||
component_editor.set_field_value_and_save('Display Name', modified_name)
|
||||
self.assertEqual(component.name, modified_name)
|
||||
|
||||
def test_edit_container_on_unit_page(self):
|
||||
"""
|
||||
Test the "edit" button on a container appearing on the unit page.
|
||||
"""
|
||||
unit = self.go_to_unit_page()
|
||||
component = unit.xblocks[1]
|
||||
self.modify_display_name_and_verify(component)
|
||||
|
||||
def test_edit_container_on_container_page(self):
|
||||
"""
|
||||
Test the "edit" button on a container appearing on the container page.
|
||||
"""
|
||||
container = self.go_to_nested_container_page()
|
||||
self.modify_display_name_and_verify(container)
|
||||
|
||||
|
||||
class BaseGroupConfigurationsTest(ContainerBase):
|
||||
ALL_LEARNERS_AND_STAFF = XBlockVisibilityEditorView.ALL_LEARNERS_AND_STAFF
|
||||
CHOOSE_ONE = "Select a group type"
|
||||
CONTENT_GROUP_PARTITION = XBlockVisibilityEditorView.CONTENT_GROUP_PARTITION
|
||||
ENROLLMENT_TRACK_PARTITION = XBlockVisibilityEditorView.ENROLLMENT_TRACK_PARTITION
|
||||
MISSING_GROUP_LABEL = 'Deleted Group\nThis group no longer exists. Choose another group or remove the access restriction.'
|
||||
VALIDATION_ERROR_LABEL = 'This component has validation issues.'
|
||||
VALIDATION_ERROR_MESSAGE = "Error:\nThis component's access settings refer to deleted or invalid groups."
|
||||
GROUP_VISIBILITY_MESSAGE = 'Access to some content in this unit is restricted to specific groups of learners.'
|
||||
MODAL_NOT_RESTRICTED_MESSAGE = "Access is not restricted"
|
||||
|
||||
def setUp(self):
|
||||
super(BaseGroupConfigurationsTest, self).setUp()
|
||||
|
||||
# Set up a cohort-schemed user partition
|
||||
self.id_base = MINIMUM_STATIC_PARTITION_ID
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
create_user_partition_json(
|
||||
self.id_base,
|
||||
self.CONTENT_GROUP_PARTITION,
|
||||
'Content Group Partition',
|
||||
[
|
||||
Group(self.id_base + 1, 'Dogs'),
|
||||
Group(self.id_base + 2, 'Cats')
|
||||
],
|
||||
scheme="cohort"
|
||||
)
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
self.container_page = self.go_to_unit_page()
|
||||
self.html_component = self.container_page.xblocks[1]
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Populate a simple course a section, subsection, and unit, and HTML component.
|
||||
"""
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('html', 'Html Component')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def edit_component_visibility(self, component):
|
||||
"""
|
||||
Edit the visibility of an xblock on the container page and returns an XBlockVisibilityEditorView.
|
||||
"""
|
||||
component.edit_visibility()
|
||||
return XBlockVisibilityEditorView(self.browser, component.locator)
|
||||
|
||||
def edit_unit_visibility(self, unit):
|
||||
"""
|
||||
Edit the visibility of a unit on the container page and returns an XBlockVisibilityEditorView.
|
||||
"""
|
||||
unit.edit_visibility()
|
||||
return XBlockVisibilityEditorView(self.browser, unit.locator)
|
||||
|
||||
def verify_current_groups_message(self, visibility_editor, expected_current_groups):
|
||||
"""
|
||||
Check that the current visibility is displayed at the top of the dialog.
|
||||
"""
|
||||
if expected_current_groups == self.ALL_LEARNERS_AND_STAFF:
|
||||
self.assertEqual("Access is not restricted", visibility_editor.current_groups_message)
|
||||
else:
|
||||
self.assertEqual(
|
||||
u"Access is restricted to: {groups}".format(groups=expected_current_groups),
|
||||
visibility_editor.current_groups_message
|
||||
)
|
||||
|
||||
def verify_selected_partition_scheme(self, visibility_editor, expected_scheme):
|
||||
"""
|
||||
Check that the expected partition scheme is selected.
|
||||
"""
|
||||
six.assertCountEqual(self, expected_scheme, visibility_editor.selected_partition_scheme)
|
||||
|
||||
def verify_selected_groups(self, visibility_editor, expected_groups):
|
||||
"""
|
||||
Check the expected partition groups.
|
||||
"""
|
||||
six.assertCountEqual(self, expected_groups, [group.text for group in visibility_editor.selected_groups])
|
||||
|
||||
def select_and_verify_saved(self, component, partition_label, groups=[]):
|
||||
"""
|
||||
Edit the visibility of an xblock on the container page and
|
||||
verify that the edit persists. Note that `groups`
|
||||
are labels which should be clicked, but not necessarily checked.
|
||||
"""
|
||||
# Make initial edit(s) and save
|
||||
visibility_editor = self.edit_component_visibility(component)
|
||||
|
||||
visibility_editor.select_groups_in_partition_scheme(partition_label, groups)
|
||||
|
||||
# Re-open the modal and inspect its selected inputs. If no groups were selected,
|
||||
# "All Learners" should be selected partitions scheme, and we show "Select a group type" in the select.
|
||||
if not groups:
|
||||
partition_label = self.CHOOSE_ONE
|
||||
visibility_editor = self.edit_component_visibility(component)
|
||||
self.verify_selected_partition_scheme(visibility_editor, partition_label)
|
||||
self.verify_selected_groups(visibility_editor, groups)
|
||||
visibility_editor.save()
|
||||
|
||||
def select_and_verify_unit_group_access(self, unit, partition_label, groups=[]):
|
||||
"""
|
||||
Edit the visibility of an xblock on the unit page and
|
||||
verify that the edit persists. Note that `groups`
|
||||
are labels which should be clicked, but are not necessarily checked.
|
||||
"""
|
||||
unit_access_editor = self.edit_unit_visibility(unit)
|
||||
unit_access_editor.select_groups_in_partition_scheme(partition_label, groups)
|
||||
|
||||
if not groups:
|
||||
partition_label = self.CHOOSE_ONE
|
||||
unit_access_editor = self.edit_unit_visibility(unit)
|
||||
self.verify_selected_partition_scheme(unit_access_editor, partition_label)
|
||||
self.verify_selected_groups(unit_access_editor, groups)
|
||||
unit_access_editor.save()
|
||||
|
||||
def verify_component_validation_error(self, component):
|
||||
"""
|
||||
Verify that we see validation errors for the given component.
|
||||
"""
|
||||
self.assertTrue(component.has_validation_error)
|
||||
self.assertEqual(component.validation_error_text, self.VALIDATION_ERROR_LABEL)
|
||||
self.assertEqual([self.VALIDATION_ERROR_MESSAGE], component.validation_error_messages)
|
||||
|
||||
def verify_visibility_set(self, component, is_set):
|
||||
"""
|
||||
Verify that the container page shows that component visibility
|
||||
settings have been edited if `is_set` is True; otherwise
|
||||
verify that the container page shows no such information.
|
||||
"""
|
||||
if is_set:
|
||||
self.assertIn(self.GROUP_VISIBILITY_MESSAGE, self.container_page.sidebar_visibility_message)
|
||||
self.assertTrue(component.has_group_visibility_set)
|
||||
else:
|
||||
self.assertNotIn(self.GROUP_VISIBILITY_MESSAGE, self.container_page.sidebar_visibility_message)
|
||||
self.assertFalse(component.has_group_visibility_set)
|
||||
|
||||
def verify_unit_visibility_set(self, unit, set_groups=[]):
|
||||
"""
|
||||
Verify that the container visibility modal shows that unit visibility
|
||||
settings have been edited if there are `set_groups`. Otherwise verify
|
||||
that the modal shows no such information.
|
||||
"""
|
||||
unit_access_editor = self.edit_unit_visibility(unit)
|
||||
if set_groups:
|
||||
self.assertIn(", ".join(set_groups), unit_access_editor.current_groups_message)
|
||||
else:
|
||||
self.assertEqual(self.MODAL_NOT_RESTRICTED_MESSAGE, unit_access_editor.current_groups_message)
|
||||
unit_access_editor.cancel()
|
||||
|
||||
def update_component(self, component, metadata):
|
||||
"""
|
||||
Update a component's metadata and refresh the page.
|
||||
"""
|
||||
self.course_fixture._update_xblock(component.locator, {'metadata': metadata})
|
||||
self.browser.refresh()
|
||||
self.container_page.wait_for_page()
|
||||
|
||||
def remove_missing_groups(self, visibility_editor, component):
|
||||
"""
|
||||
Deselect the missing groups for a component. After save,
|
||||
verify that there are no missing group messages in the modal
|
||||
and that there is no validation error on the component.
|
||||
"""
|
||||
for option in visibility_editor.all_group_options:
|
||||
if option.text == self.MISSING_GROUP_LABEL:
|
||||
option.click()
|
||||
visibility_editor.save()
|
||||
visibility_editor = self.edit_component_visibility(component)
|
||||
self.assertNotIn(self.MISSING_GROUP_LABEL, [item.text for item in visibility_editor.all_group_options])
|
||||
visibility_editor.cancel()
|
||||
self.assertFalse(component.has_validation_error)
|
||||
|
||||
|
||||
@attr(shard=21)
|
||||
class UnitAccessContainerTest(BaseGroupConfigurationsTest):
|
||||
"""
|
||||
Tests unit level access
|
||||
"""
|
||||
GROUP_RESTRICTED_MESSAGE = 'Access to this unit is restricted to: Dogs'
|
||||
|
||||
def _toggle_container_unit_access(self, group_ids, unit):
|
||||
"""
|
||||
Toggle the unit level access on the course outline page
|
||||
"""
|
||||
unit.toggle_unit_access('Content Groups', group_ids)
|
||||
|
||||
def _verify_container_unit_access_message(self, group_ids, expected_message):
|
||||
"""
|
||||
Check that the container page displays the correct unit
|
||||
access message.
|
||||
"""
|
||||
self.outline.visit()
|
||||
self.outline.expand_all_subsections()
|
||||
unit = self.outline.section_at(0).subsection_at(0).unit_at(0)
|
||||
self._toggle_container_unit_access(group_ids, unit)
|
||||
|
||||
container_page = self.go_to_unit_page()
|
||||
self.assertEqual(str(container_page.get_xblock_access_message()), expected_message)
|
||||
|
||||
def test_default_selection(self):
|
||||
"""
|
||||
Tests that no message is displayed when there are no
|
||||
restrictions on the unit or components.
|
||||
"""
|
||||
self._verify_container_unit_access_message([], '')
|
||||
|
||||
def test_restricted_components_message(self):
|
||||
"""
|
||||
Test that the proper message is displayed when access to
|
||||
some components is restricted.
|
||||
"""
|
||||
container_page = self.go_to_unit_page()
|
||||
html_component = container_page.xblocks[1]
|
||||
|
||||
# Initially set visibility to Dog group.
|
||||
self.update_component(
|
||||
html_component,
|
||||
{'group_access': {self.id_base: [self.id_base + 1]}}
|
||||
)
|
||||
|
||||
self._verify_container_unit_access_message([], self.GROUP_VISIBILITY_MESSAGE)
|
||||
|
||||
def test_restricted_access_message(self):
|
||||
"""
|
||||
Test that the proper message is displayed when access to the
|
||||
unit is restricted to a particular group.
|
||||
"""
|
||||
self._verify_container_unit_access_message([self.id_base + 1], self.GROUP_RESTRICTED_MESSAGE)
|
||||
|
||||
|
||||
@attr(shard=9)
|
||||
class ContentGroupVisibilityModalTest(BaseGroupConfigurationsTest):
|
||||
"""
|
||||
Tests of the visibility settings modal for components on the unit
|
||||
page (content groups).
|
||||
"""
|
||||
def test_default_selection(self):
|
||||
"""
|
||||
Scenario: The component visibility modal selects visible to all by default.
|
||||
Given I have a unit with one component
|
||||
When I go to the container page for that unit
|
||||
And I open the visibility editor modal for that unit's component
|
||||
Then the default visibility selection should be 'All Students and Staff'
|
||||
And the container page should not display the content visibility warning
|
||||
"""
|
||||
visibility_dialog = self.edit_component_visibility(self.html_component)
|
||||
self.verify_current_groups_message(visibility_dialog, self.ALL_LEARNERS_AND_STAFF)
|
||||
self.verify_selected_partition_scheme(visibility_dialog, self.CHOOSE_ONE)
|
||||
visibility_dialog.cancel()
|
||||
self.verify_visibility_set(self.html_component, False)
|
||||
|
||||
def test_reset_to_all_students_and_staff(self):
|
||||
"""
|
||||
Scenario: The component visibility modal can be set to be visible to all students and staff.
|
||||
Given I have a unit with one component
|
||||
When I go to the container page for that unit
|
||||
Then the container page should not display the content visibility warning by default.
|
||||
If I then restrict access and save, and then I open the visibility editor modal for that unit's component
|
||||
And I select 'All Students and Staff'
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'All Students and Staff'
|
||||
And the container page should still not display the content visibility warning
|
||||
"""
|
||||
self.select_and_verify_saved(self.html_component, self.CONTENT_GROUP_PARTITION, ['Dogs'])
|
||||
self.select_and_verify_saved(self.html_component, self.ALL_LEARNERS_AND_STAFF)
|
||||
self.verify_visibility_set(self.html_component, False)
|
||||
|
||||
def test_reset_unit_access_to_all_students_and_staff(self):
|
||||
"""
|
||||
Scenario: The unit visibility modal can be set to be visible to all students and staff.
|
||||
Given I have a unit
|
||||
When I go to the container page for that unit
|
||||
And I open the visibility editor modal for that unit
|
||||
And I select 'Dogs'
|
||||
And I save the modal
|
||||
Then I re-open the modal, the unit access modal should display the content visibility settings
|
||||
Then after re-opening the modal again
|
||||
And I select 'All Learners and Staff'
|
||||
And I save the modal
|
||||
And I re-open the modal, the unit access modal should display that no content is restricted
|
||||
"""
|
||||
self.select_and_verify_unit_group_access(self.container_page, self.CONTENT_GROUP_PARTITION, ['Dogs'])
|
||||
self.verify_unit_visibility_set(self.container_page, set_groups=["Dogs"])
|
||||
self.select_and_verify_unit_group_access(self.container_page, self.ALL_LEARNERS_AND_STAFF)
|
||||
self.verify_unit_visibility_set(self.container_page)
|
||||
|
||||
def test_select_single_content_group(self):
|
||||
"""
|
||||
Scenario: The component visibility modal can be set to be visible to one content group.
|
||||
Given I have a unit with one component
|
||||
When I go to the container page for that unit
|
||||
And I open the visibility editor modal for that unit's component
|
||||
And I select 'Dogs'
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'Dogs' and 'Specific Content Groups'
|
||||
"""
|
||||
self.select_and_verify_saved(self.html_component, self.CONTENT_GROUP_PARTITION, ['Dogs'])
|
||||
|
||||
def test_select_multiple_content_groups(self):
|
||||
"""
|
||||
Scenario: The component visibility modal can be set to be visible to multiple content groups.
|
||||
Given I have a unit with one component
|
||||
When I go to the container page for that unit
|
||||
And I open the visibility editor modal for that unit's component
|
||||
And I select 'Dogs' and 'Cats'
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'Dogs', 'Cats', and 'Specific Content Groups'
|
||||
"""
|
||||
self.select_and_verify_saved(self.html_component, self.CONTENT_GROUP_PARTITION, ['Dogs', 'Cats'])
|
||||
|
||||
def test_missing_groups(self):
|
||||
"""
|
||||
Scenario: The component visibility modal shows a validation error when visibility is set to multiple unknown
|
||||
group ids.
|
||||
Given I have a unit with one component
|
||||
And that component's group access specifies multiple invalid group ids
|
||||
When I go to the container page for that unit
|
||||
Then I should see a validation error message on that unit's component
|
||||
And I open the visibility editor modal for that unit's component
|
||||
Then I should see that I have selected multiple deleted groups
|
||||
And the container page should display the content visibility warning
|
||||
And I de-select the missing groups
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'All Students and Staff'
|
||||
And I should not see any validation errors on the component
|
||||
And the container page should not display the content visibility warning
|
||||
"""
|
||||
self.update_component(
|
||||
self.html_component,
|
||||
{'group_access': {self.id_base: [self.id_base + 3, self.id_base + 4]}}
|
||||
)
|
||||
self._verify_and_remove_missing_content_groups(
|
||||
"Deleted Group, Deleted Group",
|
||||
[self.MISSING_GROUP_LABEL] * 2
|
||||
)
|
||||
self.verify_visibility_set(self.html_component, False)
|
||||
|
||||
def test_found_and_missing_groups(self):
|
||||
"""
|
||||
Scenario: The component visibility modal shows a validation error when visibility is set to multiple unknown
|
||||
group ids and multiple known group ids.
|
||||
Given I have a unit with one component
|
||||
And that component's group access specifies multiple invalid and valid group ids
|
||||
When I go to the container page for that unit
|
||||
Then I should see a validation error message on that unit's component
|
||||
And I open the visibility editor modal for that unit's component
|
||||
Then I should see that I have selected multiple deleted groups
|
||||
And then if I de-select the missing groups
|
||||
And I save the modal
|
||||
Then the visibility selection should be the names of the valid groups.
|
||||
And I should not see any validation errors on the component
|
||||
"""
|
||||
self.update_component(
|
||||
self.html_component,
|
||||
{'group_access': {self.id_base: [self.id_base + 1, self.id_base + 2, self.id_base + 3, self.id_base + 4]}}
|
||||
)
|
||||
|
||||
self._verify_and_remove_missing_content_groups(
|
||||
'Dogs, Cats, Deleted Group, Deleted Group',
|
||||
['Dogs', 'Cats'] + [self.MISSING_GROUP_LABEL] * 2
|
||||
)
|
||||
|
||||
visibility_editor = self.edit_component_visibility(self.html_component)
|
||||
self.verify_selected_partition_scheme(visibility_editor, self.CONTENT_GROUP_PARTITION)
|
||||
expected_groups = ['Dogs', 'Cats']
|
||||
self.verify_current_groups_message(visibility_editor, ", ".join(expected_groups))
|
||||
self.verify_selected_groups(visibility_editor, expected_groups)
|
||||
|
||||
def _verify_and_remove_missing_content_groups(self, current_groups_message, all_group_labels):
|
||||
self.verify_component_validation_error(self.html_component)
|
||||
visibility_editor = self.edit_component_visibility(self.html_component)
|
||||
self.verify_selected_partition_scheme(visibility_editor, self.CONTENT_GROUP_PARTITION)
|
||||
self.verify_current_groups_message(visibility_editor, current_groups_message)
|
||||
self.verify_selected_groups(visibility_editor, all_group_labels)
|
||||
self.remove_missing_groups(visibility_editor, self.html_component)
|
||||
|
||||
|
||||
@attr(shard=20)
|
||||
class EnrollmentTrackVisibilityModalTest(BaseGroupConfigurationsTest):
|
||||
"""
|
||||
Tests of the visibility settings modal for components on the unit
|
||||
page (enrollment tracks).
|
||||
"""
|
||||
AUDIT_TRACK = "Audit Track"
|
||||
VERIFIED_TRACK = "Verified Track"
|
||||
|
||||
def setUp(self):
|
||||
super(EnrollmentTrackVisibilityModalTest, self).setUp()
|
||||
|
||||
# Add an audit mode to the course
|
||||
ModeCreationPage(self.browser, self.course_id, mode_slug=u'audit', mode_display_name=self.AUDIT_TRACK).visit()
|
||||
|
||||
# Add a verified mode to the course
|
||||
ModeCreationPage(
|
||||
self.browser, self.course_id, mode_slug=u'verified',
|
||||
mode_display_name=self.VERIFIED_TRACK, min_price=10
|
||||
).visit()
|
||||
|
||||
self.container_page = self.go_to_unit_page()
|
||||
self.html_component = self.container_page.xblocks[1]
|
||||
|
||||
# Initially set visibility to Verified track.
|
||||
self.update_component(
|
||||
self.html_component,
|
||||
{'group_access': {ENROLLMENT_TRACK_PARTITION_ID: [2]}} # "2" is Verified
|
||||
)
|
||||
|
||||
def verify_component_group_visibility_messsage(self, component, expected_groups):
|
||||
"""
|
||||
Verifies that the group visibility message below the component display name is correct.
|
||||
"""
|
||||
if not expected_groups:
|
||||
self.assertIsNone(component.get_partition_group_message)
|
||||
else:
|
||||
self.assertEqual("Access restricted to: " + expected_groups, component.get_partition_group_message)
|
||||
|
||||
def test_setting_enrollment_tracks(self):
|
||||
"""
|
||||
Test that enrollment track groups can be selected.
|
||||
"""
|
||||
# Verify that the "Verified" Group is shown on the unit page (under the unit display name).
|
||||
self.verify_component_group_visibility_messsage(self.html_component, "Verified Track")
|
||||
|
||||
# Open dialog with "Verified" already selected.
|
||||
visibility_editor = self.edit_component_visibility(self.html_component)
|
||||
self.verify_current_groups_message(visibility_editor, self.VERIFIED_TRACK)
|
||||
self.verify_selected_partition_scheme(
|
||||
visibility_editor,
|
||||
self.ENROLLMENT_TRACK_PARTITION
|
||||
)
|
||||
self.verify_selected_groups(visibility_editor, [self.VERIFIED_TRACK])
|
||||
visibility_editor.cancel()
|
||||
|
||||
# Select "All Learners and Staff". The helper method saves the change,
|
||||
# then reopens the dialog to verify that it was persisted.
|
||||
self.select_and_verify_saved(self.html_component, self.ALL_LEARNERS_AND_STAFF)
|
||||
self.verify_component_group_visibility_messsage(self.html_component, None)
|
||||
|
||||
# Select "Audit" enrollment track. The helper method saves the change,
|
||||
# then reopens the dialog to verify that it was persisted.
|
||||
self.select_and_verify_saved(self.html_component, self.ENROLLMENT_TRACK_PARTITION, [self.AUDIT_TRACK])
|
||||
self.verify_component_group_visibility_messsage(self.html_component, "Audit Track")
|
||||
|
||||
|
||||
@attr(shard=16)
|
||||
class UnitPublishingTest(ContainerBase):
|
||||
"""
|
||||
Tests of the publishing control and related widgets on the Unit page.
|
||||
"""
|
||||
|
||||
PUBLISHED_STATUS = "Publishing Status\nPublished (not yet released)"
|
||||
PUBLISHED_LIVE_STATUS = "Publishing Status\nPublished and Live"
|
||||
DRAFT_STATUS = "Publishing Status\nDraft (Unpublished changes)"
|
||||
LOCKED_STATUS = "Publishing Status\nVisible to Staff Only"
|
||||
RELEASE_TITLE_RELEASED = "RELEASED:"
|
||||
RELEASE_TITLE_RELEASE = "RELEASE:"
|
||||
|
||||
LAST_PUBLISHED = 'Last published'
|
||||
LAST_SAVED = 'Draft saved on'
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Sets up a course structure with a unit and a single HTML child.
|
||||
"""
|
||||
|
||||
self.html_content = '<p><strong>Body of HTML Unit.</strong></p>'
|
||||
self.courseware = CoursewarePage(self.browser, self.course_id)
|
||||
past_start_date = datetime.datetime(1974, 6, 22)
|
||||
self.past_start_date_text = "Jun 22, 1974 at 00:00 UTC"
|
||||
future_start_date = datetime.datetime(2100, 9, 13)
|
||||
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('html', 'Test html', data=self.html_content)
|
||||
)
|
||||
)
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
'chapter',
|
||||
'Unlocked Section',
|
||||
metadata={'start': past_start_date.isoformat()}
|
||||
).add_children(
|
||||
XBlockFixtureDesc('sequential', 'Unlocked Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Unlocked Unit').add_children(
|
||||
XBlockFixtureDesc('problem', '<problem></problem>', data=self.html_content)
|
||||
)
|
||||
)
|
||||
),
|
||||
XBlockFixtureDesc('chapter', 'Section With Locked Unit').add_children(
|
||||
XBlockFixtureDesc(
|
||||
'sequential',
|
||||
'Subsection With Locked Unit',
|
||||
metadata={'start': past_start_date.isoformat()}
|
||||
).add_children(
|
||||
XBlockFixtureDesc(
|
||||
'vertical',
|
||||
'Locked Unit',
|
||||
metadata={'visible_to_staff_only': True}
|
||||
).add_children(
|
||||
XBlockFixtureDesc('discussion', '', data=self.html_content)
|
||||
)
|
||||
)
|
||||
),
|
||||
XBlockFixtureDesc(
|
||||
'chapter',
|
||||
'Unreleased Section',
|
||||
metadata={'start': future_start_date.isoformat()}
|
||||
).add_children(
|
||||
XBlockFixtureDesc('sequential', 'Unreleased Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Unreleased Unit')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_publishing(self):
|
||||
"""
|
||||
Scenario: The publish title changes based on whether or not draft content exists
|
||||
Given I have a published unit with no unpublished changes
|
||||
When I go to the unit page in Studio
|
||||
Then the title in the Publish information box is "Published and Live"
|
||||
And the Publish button is disabled
|
||||
And the last published text contains "Last published"
|
||||
And the last saved text contains "Last published"
|
||||
And when I add a component to the unit
|
||||
Then the title in the Publish information box is "Draft (Unpublished changes)"
|
||||
And the last saved text contains "Draft saved on"
|
||||
And the Publish button is enabled
|
||||
And when I click the Publish button
|
||||
Then the title in the Publish information box is "Published and Live"
|
||||
And the last published text contains "Last published"
|
||||
And the last saved text contains "Last published"
|
||||
"""
|
||||
unit = self.go_to_unit_page()
|
||||
unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
# Start date set in course fixture to 1970.
|
||||
self._verify_release_date_info(
|
||||
unit, self.RELEASE_TITLE_RELEASED, 'Jan 01, 1970 at 00:00 UTC\nwith Section "Test Section"'
|
||||
)
|
||||
self._verify_last_published_and_saved(unit, self.LAST_PUBLISHED, self.LAST_PUBLISHED)
|
||||
# Should not be able to click on Publish action -- but I don't know how to test that it is not clickable.
|
||||
# TODO: continue discussion with Muhammad and Jay about this.
|
||||
|
||||
# Add a component to the page so it will have unpublished changes.
|
||||
add_discussion(unit)
|
||||
unit.verify_publish_title(self.DRAFT_STATUS)
|
||||
self._verify_last_published_and_saved(unit, self.LAST_PUBLISHED, self.LAST_SAVED)
|
||||
unit.publish()
|
||||
unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
self._verify_last_published_and_saved(unit, self.LAST_PUBLISHED, self.LAST_PUBLISHED)
|
||||
|
||||
def test_discard_changes(self):
|
||||
"""
|
||||
Scenario: The publish title changes after "Discard Changes" is clicked
|
||||
Given I have a published unit with no unpublished changes
|
||||
When I go to the unit page in Studio
|
||||
Then the Discard Changes button is disabled
|
||||
And I add a component to the unit
|
||||
Then the title in the Publish information box is "Draft (Unpublished changes)"
|
||||
And the Discard Changes button is enabled
|
||||
And when I click the Discard Changes button
|
||||
Then the title in the Publish information box is "Published and Live"
|
||||
"""
|
||||
unit = self.go_to_unit_page()
|
||||
add_discussion(unit)
|
||||
unit.verify_publish_title(self.DRAFT_STATUS)
|
||||
unit.discard_changes()
|
||||
unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
|
||||
def test_view_live_no_changes(self):
|
||||
"""
|
||||
Scenario: "View Live" shows published content in LMS
|
||||
Given I have a published unit with no unpublished changes
|
||||
When I go to the unit page in Studio
|
||||
Then the View Live button is enabled
|
||||
And when I click on the View Live button
|
||||
Then I see the published content in LMS
|
||||
"""
|
||||
unit = self.go_to_unit_page()
|
||||
self._view_published_version(unit)
|
||||
self._verify_components_visible(['html'])
|
||||
|
||||
def test_view_live_changes(self):
|
||||
"""
|
||||
Scenario: "View Live" does not show draft content in LMS
|
||||
Given I have a published unit with no unpublished changes
|
||||
When I go to the unit page in Studio
|
||||
And when I add a component to the unit
|
||||
And when I click on the View Live button
|
||||
Then I see the published content in LMS
|
||||
And I do not see the unpublished component
|
||||
"""
|
||||
unit = self.go_to_unit_page()
|
||||
add_discussion(unit)
|
||||
self._view_published_version(unit)
|
||||
self._verify_components_visible(['html'])
|
||||
self.assertEqual(self.html_content, self.courseware.xblock_component_html_content(0))
|
||||
|
||||
def test_view_live_after_publish(self):
|
||||
"""
|
||||
Scenario: "View Live" shows newly published content
|
||||
Given I have a published unit with no unpublished changes
|
||||
When I go to the unit page in Studio
|
||||
And when I add a component to the unit
|
||||
And when I click the Publish button
|
||||
And when I click on the View Live button
|
||||
Then I see the newly published component
|
||||
"""
|
||||
unit = self.go_to_unit_page()
|
||||
add_discussion(unit)
|
||||
unit.publish()
|
||||
self._view_published_version(unit)
|
||||
self._verify_components_visible(['html', 'discussion'])
|
||||
|
||||
def test_initially_unlocked_visible_to_students(self):
|
||||
"""
|
||||
Scenario: An unlocked unit with release date in the past is visible to students
|
||||
Given I have a published unlocked unit with release date in the past
|
||||
When I go to the unit page in Studio
|
||||
Then the unit has a warning that it is visible to students
|
||||
And it is marked as "RELEASED" with release date in the past visible
|
||||
And when I click on the View Live Button
|
||||
And when I view the course as a student
|
||||
Then I see the content in the unit
|
||||
"""
|
||||
unit = self.go_to_unit_page("Unlocked Section", "Unlocked Subsection", "Unlocked Unit")
|
||||
unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
self.assertTrue(unit.currently_visible_to_students)
|
||||
self._verify_release_date_info(
|
||||
unit, self.RELEASE_TITLE_RELEASED, self.past_start_date_text + '\n' + 'with Section "Unlocked Section"'
|
||||
)
|
||||
self._view_published_version(unit)
|
||||
self._verify_student_view_visible(['problem'])
|
||||
|
||||
def test_locked_visible_to_staff_only(self):
|
||||
"""
|
||||
Scenario: After locking a unit with release date in the past, it is only visible to staff
|
||||
Given I have a published unlocked unit with release date in the past
|
||||
When I go to the unit page in Studio
|
||||
And when I select "Hide from students"
|
||||
Then the unit does not have a warning that it is visible to students
|
||||
And the unit does not display inherited staff lock
|
||||
And when I click on the View Live Button
|
||||
Then I see the content in the unit when logged in as staff
|
||||
And when I view the course as a student
|
||||
Then I do not see any content in the unit
|
||||
"""
|
||||
unit = self.go_to_unit_page("Unlocked Section", "Unlocked Subsection", "Unlocked Unit")
|
||||
checked = unit.toggle_staff_lock()
|
||||
self.assertTrue(checked)
|
||||
self.assertFalse(unit.currently_visible_to_students)
|
||||
self.assertFalse(unit.shows_inherited_staff_lock())
|
||||
unit.verify_publish_title(self.LOCKED_STATUS)
|
||||
self._view_published_version(unit)
|
||||
# Will initially be in staff view, locked component should be visible.
|
||||
self._verify_components_visible(['problem'])
|
||||
# Switch to student view and verify not visible
|
||||
self._verify_student_view_locked()
|
||||
|
||||
def test_initially_locked_not_visible_to_students(self):
|
||||
"""
|
||||
Scenario: A locked unit with release date in the past is not visible to students
|
||||
Given I have a published locked unit with release date in the past
|
||||
When I go to the unit page in Studio
|
||||
Then the unit does not have a warning that it is visible to students
|
||||
And it is marked as "RELEASE" with release date in the past visible
|
||||
And when I click on the View Live Button
|
||||
And when I view the course as a student
|
||||
Then I do not see any content in the unit
|
||||
"""
|
||||
unit = self.go_to_unit_page("Section With Locked Unit", "Subsection With Locked Unit", "Locked Unit")
|
||||
unit.verify_publish_title(self.LOCKED_STATUS)
|
||||
self.assertFalse(unit.currently_visible_to_students)
|
||||
self._verify_release_date_info(
|
||||
unit, self.RELEASE_TITLE_RELEASE,
|
||||
self.past_start_date_text + '\n' + 'with Subsection "Subsection With Locked Unit"'
|
||||
)
|
||||
self._view_published_version(unit)
|
||||
self._verify_student_view_locked()
|
||||
|
||||
def test_unlocked_visible_to_all(self):
|
||||
"""
|
||||
Scenario: After unlocking a unit with release date in the past, it is visible to both students and staff
|
||||
Given I have a published unlocked unit with release date in the past
|
||||
When I go to the unit page in Studio
|
||||
And when I deselect "Hide from students"
|
||||
Then the unit does have a warning that it is visible to students
|
||||
And when I click on the View Live Button
|
||||
Then I see the content in the unit when logged in as staff
|
||||
And when I view the course as a student
|
||||
Then I see the content in the unit
|
||||
"""
|
||||
unit = self.go_to_unit_page("Section With Locked Unit", "Subsection With Locked Unit", "Locked Unit")
|
||||
checked = unit.toggle_staff_lock()
|
||||
self.assertFalse(checked)
|
||||
unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
self.assertTrue(unit.currently_visible_to_students)
|
||||
self._view_published_version(unit)
|
||||
# Will initially be in staff view, components always visible.
|
||||
self._verify_components_visible(['discussion'])
|
||||
# Switch to student view and verify visible.
|
||||
self._verify_student_view_visible(['discussion'])
|
||||
|
||||
def test_explicit_lock_overrides_implicit_subsection_lock_information(self):
|
||||
"""
|
||||
Scenario: A unit's explicit staff lock hides its inherited subsection staff lock information
|
||||
Given I have a course with sections, subsections, and units
|
||||
And I have enabled explicit staff lock on a subsection
|
||||
When I visit the unit page
|
||||
Then the unit page shows its inherited staff lock
|
||||
And I enable explicit staff locking
|
||||
Then the unit page does not show its inherited staff lock
|
||||
And when I disable explicit staff locking
|
||||
Then the unit page now shows its inherited staff lock
|
||||
"""
|
||||
self.outline.visit()
|
||||
self.outline.expand_all_subsections()
|
||||
subsection = self.outline.section_at(0).subsection_at(0)
|
||||
unit = subsection.unit_at(0)
|
||||
subsection.set_staff_lock(True)
|
||||
unit_page = unit.go_to()
|
||||
self._verify_explicit_lock_overrides_implicit_lock_information(unit_page)
|
||||
|
||||
def test_explicit_lock_overrides_implicit_section_lock_information(self):
|
||||
"""
|
||||
Scenario: A unit's explicit staff lock hides its inherited subsection staff lock information
|
||||
Given I have a course with sections, subsections, and units
|
||||
And I have enabled explicit staff lock on a section
|
||||
When I visit the unit page
|
||||
Then the unit page shows its inherited staff lock
|
||||
And I enable explicit staff locking
|
||||
Then the unit page does not show its inherited staff lock
|
||||
And when I disable explicit staff locking
|
||||
Then the unit page now shows its inherited staff lock
|
||||
"""
|
||||
self.outline.visit()
|
||||
self.outline.expand_all_subsections()
|
||||
section = self.outline.section_at(0)
|
||||
unit = section.subsection_at(0).unit_at(0)
|
||||
section.set_staff_lock(True)
|
||||
unit_page = unit.go_to()
|
||||
self._verify_explicit_lock_overrides_implicit_lock_information(unit_page)
|
||||
|
||||
def test_cancel_does_not_create_draft(self):
|
||||
"""
|
||||
Scenario: Editing a component and then canceling does not create a draft version (TNL-399)
|
||||
Given I have a published unit with no unpublished changes
|
||||
When I go to the unit page in Studio
|
||||
And edit the content of an HTML component and then press cancel
|
||||
Then the content does not change
|
||||
And the title in the Publish information box is "Published and Live"
|
||||
And when I reload the page
|
||||
Then the title in the Publish information box is "Published and Live"
|
||||
"""
|
||||
unit = self.go_to_unit_page()
|
||||
component = unit.xblocks[1]
|
||||
component.edit()
|
||||
HtmlXBlockEditorView(self.browser, component.locator).set_content_and_cancel("modified content")
|
||||
self.assertEqual(component.student_content, "Body of HTML Unit.")
|
||||
unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
self.browser.refresh()
|
||||
unit.wait_for_page()
|
||||
unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
|
||||
def test_delete_child_in_published_unit(self):
|
||||
"""
|
||||
Scenario: A published unit can be published again after deleting a child
|
||||
Given I have a published unit with no unpublished changes
|
||||
When I go to the unit page in Studio
|
||||
And delete the only component
|
||||
Then the title in the Publish information box is "Draft (Unpublished changes)"
|
||||
And when I click the Publish button
|
||||
Then the title in the Publish information box is "Published and Live"
|
||||
And when I click the View Live button
|
||||
Then I see an empty unit in LMS
|
||||
"""
|
||||
unit = self.go_to_unit_page()
|
||||
unit.delete(0)
|
||||
unit.verify_publish_title(self.DRAFT_STATUS)
|
||||
unit.publish()
|
||||
unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
self._view_published_version(unit)
|
||||
self.assertEqual(0, self.courseware.num_xblock_components)
|
||||
|
||||
def test_published_not_live(self):
|
||||
"""
|
||||
Scenario: The publish title displays correctly for units that are not live
|
||||
Given I have a published unit with no unpublished changes that releases in the future
|
||||
When I go to the unit page in Studio
|
||||
Then the title in the Publish information box is "Published (not yet released)"
|
||||
And when I add a component to the unit
|
||||
Then the title in the Publish information box is "Draft (Unpublished changes)"
|
||||
And when I click the Publish button
|
||||
Then the title in the Publish information box is "Published (not yet released)"
|
||||
"""
|
||||
unit = self.go_to_unit_page('Unreleased Section', 'Unreleased Subsection', 'Unreleased Unit')
|
||||
unit.verify_publish_title(self.PUBLISHED_STATUS)
|
||||
add_discussion(unit)
|
||||
unit.verify_publish_title(self.DRAFT_STATUS)
|
||||
unit.publish()
|
||||
unit.verify_publish_title(self.PUBLISHED_STATUS)
|
||||
|
||||
def _view_published_version(self, unit):
|
||||
"""
|
||||
Goes to the published version, then waits for the browser to load the page.
|
||||
"""
|
||||
unit.view_published_version()
|
||||
self.assertEqual(len(self.browser.window_handles), 2)
|
||||
self.courseware.wait_for_page()
|
||||
|
||||
def _verify_and_return_staff_page(self):
|
||||
"""
|
||||
Verifies that the browser is on the staff page and returns a StaffCoursewarePage.
|
||||
"""
|
||||
page = StaffCoursewarePage(self.browser, self.course_id)
|
||||
page.wait_for_page()
|
||||
return page
|
||||
|
||||
def _verify_student_view_locked(self):
|
||||
"""
|
||||
Verifies no component is visible when viewing as a student.
|
||||
"""
|
||||
page = self._verify_and_return_staff_page()
|
||||
page.set_staff_view_mode('Learner')
|
||||
page.wait_for(lambda: self.courseware.num_xblock_components == 0, 'No XBlocks visible')
|
||||
|
||||
def _verify_student_view_visible(self, expected_components):
|
||||
"""
|
||||
Verifies expected components are visible when viewing as a student.
|
||||
"""
|
||||
self._verify_and_return_staff_page().set_staff_view_mode('Learner')
|
||||
self._verify_components_visible(expected_components)
|
||||
|
||||
def _verify_components_visible(self, expected_components):
|
||||
"""
|
||||
Verifies the expected components are visible (and there are no extras).
|
||||
"""
|
||||
self.assertEqual(len(expected_components), self.courseware.num_xblock_components)
|
||||
for index, component in enumerate(expected_components):
|
||||
self.assertEqual(component, self.courseware.xblock_component_type(index))
|
||||
|
||||
def _verify_release_date_info(self, unit, expected_title, expected_date):
|
||||
"""
|
||||
Verifies how the release date is displayed in the publishing sidebar.
|
||||
"""
|
||||
self.assertEqual(expected_title, unit.release_title)
|
||||
self.assertEqual(expected_date, unit.release_date)
|
||||
|
||||
def _verify_last_published_and_saved(self, unit, expected_published_prefix, expected_saved_prefix):
|
||||
"""
|
||||
Verifies that last published and last saved messages respectively contain the given strings.
|
||||
"""
|
||||
self.assertIn(expected_published_prefix, unit.last_published_text)
|
||||
self.assertIn(expected_saved_prefix, unit.last_saved_text)
|
||||
|
||||
def _verify_explicit_lock_overrides_implicit_lock_information(self, unit_page):
|
||||
"""
|
||||
Verifies that a unit with inherited staff lock does not display inherited information when explicitly locked.
|
||||
"""
|
||||
self.assertTrue(unit_page.shows_inherited_staff_lock())
|
||||
unit_page.toggle_staff_lock(inherits_staff_lock=True)
|
||||
self.assertFalse(unit_page.shows_inherited_staff_lock())
|
||||
unit_page.toggle_staff_lock(inherits_staff_lock=True)
|
||||
self.assertTrue(unit_page.shows_inherited_staff_lock())
|
||||
|
||||
# TODO: need to work with Jay/Christine to get testing of "Preview" working.
|
||||
# def test_preview(self):
|
||||
# unit = self.go_to_unit_page()
|
||||
# add_discussion(unit)
|
||||
# unit.preview()
|
||||
# self.assertEqual(2, self.courseware.num_xblock_components)
|
||||
# self.assertEqual('html', self.courseware.xblock_component_type(0))
|
||||
# self.assertEqual('discussion', self.courseware.xblock_component_type(1))
|
||||
|
||||
|
||||
@attr(shard=20)
|
||||
class DisplayNameTest(ContainerBase):
|
||||
"""
|
||||
Test consistent use of display_name_with_default
|
||||
"""
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Sets up a course structure with nested verticals.
|
||||
"""
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('vertical', None)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_display_name_default(self):
|
||||
"""
|
||||
Scenario: Given that an XBlock with a dynamic display name has been added to the course,
|
||||
When I view the unit page and note the display name of the block,
|
||||
Then I see the dynamically generated display name,
|
||||
And when I then go to the container page for that same block,
|
||||
Then I see the same generated display name.
|
||||
"""
|
||||
# Unfortunately no blocks in the core platform implement display_name_with_default
|
||||
# in an interesting way for this test, so we are just testing for consistency and not
|
||||
# the actual value.
|
||||
unit = self.go_to_unit_page()
|
||||
test_block = unit.xblocks[1]
|
||||
title_on_unit_page = test_block.name
|
||||
container = test_block.go_to_container()
|
||||
self.assertEqual(container.name, title_on_unit_page)
|
||||
|
||||
|
||||
@attr(shard=3)
|
||||
class ProblemCategoryTabsTest(ContainerBase):
|
||||
"""
|
||||
Test to verify tabs in problem category.
|
||||
"""
|
||||
def setUp(self, is_staff=True):
|
||||
super(ProblemCategoryTabsTest, self).setUp(is_staff=is_staff)
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Sets up course structure.
|
||||
"""
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_correct_tabs_present(self):
|
||||
"""
|
||||
Scenario: Verify that correct tabs are present in problem category.
|
||||
|
||||
Given I am a staff user
|
||||
When I go to unit page
|
||||
Then I only see `Common Problem Types` and `Advanced` tabs in `problem` category
|
||||
"""
|
||||
self.go_to_unit_page()
|
||||
page = ContainerPage(self.browser, None)
|
||||
self.assertEqual(page.get_category_tab_names('problem'), ['Common Problem Types', 'Advanced'])
|
||||
|
||||
def test_common_problem_types_tab(self):
|
||||
"""
|
||||
Scenario: Verify that correct components are present in Common Problem Types tab.
|
||||
|
||||
Given I am a staff user
|
||||
When I go to unit page
|
||||
Then I see correct components under `Common Problem Types` tab in `problem` category
|
||||
"""
|
||||
self.go_to_unit_page()
|
||||
page = ContainerPage(self.browser, None)
|
||||
|
||||
expected_components = [
|
||||
"Blank Common Problem",
|
||||
"Checkboxes",
|
||||
"Dropdown",
|
||||
"Multiple Choice",
|
||||
"Numerical Input",
|
||||
"Text Input",
|
||||
"Checkboxes with Hints and Feedback",
|
||||
"Dropdown with Hints and Feedback",
|
||||
"Multiple Choice with Hints and Feedback",
|
||||
"Numerical Input with Hints and Feedback",
|
||||
"Text Input with Hints and Feedback",
|
||||
]
|
||||
self.assertEqual(page.get_category_tab_components('problem', 1), expected_components)
|
||||
|
||||
|
||||
@attr(shard=16)
|
||||
@ddt.ddt
|
||||
class MoveComponentTest(ContainerBase):
|
||||
"""
|
||||
Tests of moving an XBlock to another XBlock.
|
||||
"""
|
||||
PUBLISHED_LIVE_STATUS = "Publishing Status\nPublished and Live"
|
||||
DRAFT_STATUS = "Publishing Status\nDraft (Unpublished changes)"
|
||||
|
||||
def setUp(self, is_staff=True):
|
||||
super(MoveComponentTest, self).setUp(is_staff=is_staff)
|
||||
self.container = ContainerPage(self.browser, None)
|
||||
self.move_modal_view = MoveModalView(self.browser)
|
||||
|
||||
self.navigation_options = {
|
||||
'section': 0,
|
||||
'subsection': 0,
|
||||
'unit': 1,
|
||||
}
|
||||
self.source_component_display_name = 'HTML 11'
|
||||
self.source_xblock_category = 'component'
|
||||
self.message_move = u'Success! "{display_name}" has been moved.'
|
||||
self.message_undo = u'Move cancelled. "{display_name}" has been moved back to its original location.'
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Sets up a course structure.
|
||||
"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self.unit_page1 = XBlockFixtureDesc('vertical', 'Test Unit 1').add_children(
|
||||
XBlockFixtureDesc('html', 'HTML 11'),
|
||||
XBlockFixtureDesc('html', 'HTML 12')
|
||||
)
|
||||
self.unit_page2 = XBlockFixtureDesc('vertical', 'Test Unit 2').add_children(
|
||||
XBlockFixtureDesc('html', 'HTML 21'),
|
||||
XBlockFixtureDesc('html', 'HTML 22')
|
||||
)
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
self.unit_page1,
|
||||
self.unit_page2
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def verify_move_opertions(self, unit_page, source_component, operation, component_display_names_after_operation,
|
||||
should_verify_publish_title=True):
|
||||
"""
|
||||
Verify move operations.
|
||||
|
||||
Arguments:
|
||||
unit_page (Object) Unit container page.
|
||||
source_component (Object) Source XBlock object to be moved.
|
||||
operation (str), `move` or `undo move` operation.
|
||||
component_display_names_after_operation (dict) Display names of components after operation in source/dest
|
||||
should_verify_publish_title (Boolean) Should verify publish title ot not. Default is True.
|
||||
"""
|
||||
source_component.open_move_modal()
|
||||
self.move_modal_view.navigate_to_category(self.source_xblock_category, self.navigation_options)
|
||||
self.assertEqual(self.move_modal_view.is_move_button_enabled, True)
|
||||
|
||||
# Verify unit is in published state before move operation
|
||||
if should_verify_publish_title:
|
||||
self.container.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
|
||||
self.move_modal_view.click_move_button()
|
||||
self.container.verify_confirmation_message(
|
||||
self.message_move.format(display_name=self.source_component_display_name)
|
||||
)
|
||||
self.assertEqual(len(unit_page.displayed_children), 1)
|
||||
|
||||
# Verify unit in draft state now
|
||||
if should_verify_publish_title:
|
||||
self.container.verify_publish_title(self.DRAFT_STATUS)
|
||||
|
||||
if operation == 'move':
|
||||
self.container.click_take_me_there_link()
|
||||
elif operation == 'undo_move':
|
||||
self.container.click_undo_move_link()
|
||||
self.container.verify_confirmation_message(
|
||||
self.message_undo.format(display_name=self.source_component_display_name)
|
||||
)
|
||||
|
||||
unit_page = ContainerPage(self.browser, None)
|
||||
components = unit_page.displayed_children
|
||||
self.assertEqual(
|
||||
[component.name for component in components],
|
||||
component_display_names_after_operation
|
||||
)
|
||||
|
||||
def verify_state_change(self, unit_page, operation):
|
||||
"""
|
||||
Verify that after state change, confirmation message is hidden.
|
||||
|
||||
Arguments:
|
||||
unit_page (Object) Unit container page.
|
||||
operation (String) Publish or discard changes operation.
|
||||
"""
|
||||
# Verify unit in draft state now
|
||||
self.container.verify_publish_title(self.DRAFT_STATUS)
|
||||
|
||||
# Now click publish/discard button
|
||||
if operation == 'publish':
|
||||
unit_page.publish()
|
||||
else:
|
||||
unit_page.discard_changes()
|
||||
|
||||
# Now verify success message is hidden
|
||||
self.container.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
self.container.verify_confirmation_message(
|
||||
message=self.message_move.format(display_name=self.source_component_display_name),
|
||||
verify_hidden=True
|
||||
)
|
||||
|
||||
def test_move_component_successfully(self):
|
||||
"""
|
||||
Test if we can move a component successfully.
|
||||
|
||||
Given I am a staff user
|
||||
And I go to unit page in first section
|
||||
And I open the move modal
|
||||
And I navigate to unit in second section
|
||||
And I see move button is enabled
|
||||
When I click on the move button
|
||||
Then I see move operation success message
|
||||
And When I click on take me there link
|
||||
Then I see moved component there.
|
||||
"""
|
||||
unit_page = self.go_to_unit_page(unit_name='Test Unit 1')
|
||||
components = unit_page.displayed_children
|
||||
self.assertEqual(len(components), 2)
|
||||
|
||||
self.verify_move_opertions(
|
||||
unit_page=unit_page,
|
||||
source_component=components[0],
|
||||
operation='move',
|
||||
component_display_names_after_operation=['HTML 21', 'HTML 22', 'HTML 11']
|
||||
)
|
||||
|
||||
@ddt.data('publish', 'discard')
|
||||
def test_publish_discard_changes_afer_move(self, operation):
|
||||
"""
|
||||
Test if success banner is hidden when we discard changes or publish the unit after a move operation.
|
||||
|
||||
Given I am a staff user
|
||||
And I go to unit page in first section
|
||||
And I open the move modal
|
||||
And I navigate to unit in second section
|
||||
And I see move button is enabled
|
||||
When I click on the move button
|
||||
Then I see move operation success message
|
||||
And When I click on publish or discard changes button
|
||||
Then I see move operation success message is hidden.
|
||||
"""
|
||||
unit_page = self.go_to_unit_page(unit_name='Test Unit 1')
|
||||
components = unit_page.displayed_children
|
||||
self.assertEqual(len(components), 2)
|
||||
|
||||
components[0].open_move_modal()
|
||||
self.move_modal_view.navigate_to_category(self.source_xblock_category, self.navigation_options)
|
||||
self.assertEqual(self.move_modal_view.is_move_button_enabled, True)
|
||||
|
||||
# Verify unit is in published state before move operation
|
||||
self.container.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
|
||||
self.move_modal_view.click_move_button()
|
||||
self.container.verify_confirmation_message(
|
||||
self.message_move.format(display_name=self.source_component_display_name)
|
||||
)
|
||||
self.assertEqual(len(unit_page.displayed_children), 1)
|
||||
|
||||
self.verify_state_change(unit_page, operation)
|
||||
|
||||
def test_content_experiment(self):
|
||||
"""
|
||||
Test if we can move a component of content experiment successfully.
|
||||
|
||||
Given that I am a staff user
|
||||
And I go to content experiment page
|
||||
And I open the move dialogue modal
|
||||
When I navigate to the unit in second section
|
||||
Then I see move button is enabled
|
||||
And when I click on the move button
|
||||
Then I see move operation success message
|
||||
And when I click on take me there link
|
||||
Then I see moved component there
|
||||
And when I undo move a component
|
||||
Then I see that undo move operation success message
|
||||
"""
|
||||
# Add content experiment support to course.
|
||||
self.course_fixture.add_advanced_settings({
|
||||
u'advanced_modules': {'value': ['split_test']},
|
||||
})
|
||||
|
||||
# Create group configurations
|
||||
# pylint: disable=protected-access
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
'metadata': {
|
||||
u'user_partitions': [
|
||||
create_user_partition_json(
|
||||
0,
|
||||
'Test Group Configuration',
|
||||
'Description of the group configuration.',
|
||||
[Group('0', 'Group A'), Group('1', 'Group B')]
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
# Add split test to unit_page1 and assign newly created group configuration to it
|
||||
split_test = XBlockFixtureDesc('split_test', 'Test Content Experiment', metadata={'user_partition_id': 0})
|
||||
self.course_fixture.create_xblock(self.unit_page1.locator, split_test)
|
||||
|
||||
# Visit content experiment container page.
|
||||
unit_page = ContainerPage(self.browser, split_test.locator)
|
||||
unit_page.visit()
|
||||
|
||||
group_a_locator = unit_page.displayed_children[0].locator
|
||||
|
||||
# Add some components to Group A.
|
||||
self.course_fixture.create_xblock(
|
||||
group_a_locator, XBlockFixtureDesc('html', 'HTML 311')
|
||||
)
|
||||
self.course_fixture.create_xblock(
|
||||
group_a_locator, XBlockFixtureDesc('html', 'HTML 312')
|
||||
)
|
||||
|
||||
# Go to group page to move it's component.
|
||||
group_container_page = ContainerPage(self.browser, group_a_locator)
|
||||
group_container_page.visit()
|
||||
|
||||
# Verify content experiment block has correct groups and components.
|
||||
components = group_container_page.displayed_children
|
||||
self.assertEqual(len(components), 2)
|
||||
|
||||
self.source_component_display_name = 'HTML 311'
|
||||
|
||||
# Verify undo move operation for content experiment.
|
||||
self.verify_move_opertions(
|
||||
unit_page=group_container_page,
|
||||
source_component=components[0],
|
||||
operation='undo_move',
|
||||
component_display_names_after_operation=['HTML 311', 'HTML 312'],
|
||||
should_verify_publish_title=False
|
||||
)
|
||||
|
||||
# Verify move operation for content experiment.
|
||||
self.verify_move_opertions(
|
||||
unit_page=group_container_page,
|
||||
source_component=components[0],
|
||||
operation='move',
|
||||
component_display_names_after_operation=['HTML 21', 'HTML 22', 'HTML 311'],
|
||||
should_verify_publish_title=False
|
||||
)
|
||||
|
||||
# Ideally this test should be decorated with @attr('a11y') so that it should run in a11y jenkins job
|
||||
# But for some reason it always fails in a11y jenkins job and passes always locally on devstack as well
|
||||
# as in bokchoy jenkins job. Due to this reason, test is marked to run under bokchoy jenkins job.
|
||||
def test_a11y(self):
|
||||
"""
|
||||
Verify move modal a11y.
|
||||
"""
|
||||
unit_page = self.go_to_unit_page(unit_name='Test Unit 1')
|
||||
|
||||
unit_page.a11y_audit.config.set_scope(
|
||||
include=[".modal-window.move-modal"]
|
||||
)
|
||||
unit_page.a11y_audit.config.set_rules({
|
||||
'ignore': [
|
||||
'color-contrast', # TODO: AC-716
|
||||
'link-href', # TODO: AC-716
|
||||
]
|
||||
})
|
||||
|
||||
unit_page.displayed_children[0].open_move_modal()
|
||||
|
||||
for category in ['section', 'subsection', 'component']:
|
||||
self.move_modal_view.navigate_to_category(category, self.navigation_options)
|
||||
unit_page.a11y_audit.check_for_accessibility_errors()
|
||||
@@ -1,74 +0,0 @@
|
||||
"""
|
||||
Acceptance tests for discussion component in studio
|
||||
"""
|
||||
|
||||
|
||||
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.studio.container import ContainerPage
|
||||
from common.test.acceptance.pages.studio.discussion_component_editor import DiscussionComponentEditor
|
||||
from common.test.acceptance.pages.studio.utils import add_component
|
||||
from common.test.acceptance.tests.studio.base_studio_test import ContainerBase
|
||||
|
||||
|
||||
class DiscussionComponentTest(ContainerBase):
|
||||
"""
|
||||
Feature: CMS.Component Adding
|
||||
As a course author, I want to be able to add and edit Discussion component
|
||||
"""
|
||||
shard = 20
|
||||
|
||||
def setUp(self, is_staff=True):
|
||||
"""
|
||||
Create a course with a section, subsection, and unit to which to add the component.
|
||||
"""
|
||||
super(DiscussionComponentTest, self).setUp(is_staff=is_staff)
|
||||
self.component = 'discussion'
|
||||
self.unit = self.go_to_unit_page()
|
||||
self.container_page = ContainerPage(self.browser, None)
|
||||
# Add Discussion component
|
||||
add_component(self.container_page, 'discussion', self.component)
|
||||
self.component = self.unit.xblocks[1]
|
||||
self.container_page.edit()
|
||||
self.discussion_editor = DiscussionComponentEditor(self.browser, self.component.locator)
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Adds a course fixture
|
||||
"""
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_view_discussion_component_metadata(self):
|
||||
"""
|
||||
Scenario: Staff user can view discussion component metadata
|
||||
Given I am in Studio and I have added a Discussion component
|
||||
When I edit Discussion component
|
||||
Then I see three settings and their expected values
|
||||
"""
|
||||
|
||||
field_values = self.discussion_editor.edit_discussion_field_values
|
||||
self.assertEqual(
|
||||
field_values,
|
||||
['Discussion', 'Week 1', 'Topic-Level Student-Visible Label']
|
||||
)
|
||||
|
||||
def test_edit_discussion_component(self):
|
||||
"""
|
||||
Scenario: Staff user can modify display name
|
||||
Given I am in Studio and I have added a Discussion component
|
||||
When I open Discussion component's edit dialogue
|
||||
Then I can modify the display name
|
||||
And My display name change is persisted on save
|
||||
"""
|
||||
|
||||
field_name = 'Display Name'
|
||||
new_name = 'Test Name'
|
||||
self.discussion_editor.set_field_val(field_name, new_name)
|
||||
self.discussion_editor.save()
|
||||
component_name = self.unit.xblock_titles[0]
|
||||
self.assertEqual(component_name, new_name)
|
||||
@@ -1,268 +0,0 @@
|
||||
"""
|
||||
Acceptance tests for Studio.
|
||||
"""
|
||||
|
||||
|
||||
import uuid
|
||||
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.studio import LMS_URL
|
||||
from common.test.acceptance.pages.studio.asset_index import AssetIndexPageStudioFrontend
|
||||
from common.test.acceptance.pages.studio.course_info import CourseUpdatesPage
|
||||
from common.test.acceptance.pages.studio.edit_tabs import PagesPage
|
||||
from common.test.acceptance.pages.studio.import_export import ExportCoursePage, ImportCoursePage
|
||||
from common.test.acceptance.pages.studio.index import AccessibilityPage, DashboardPage, HomePage, IndexPage
|
||||
from common.test.acceptance.pages.studio.login import CourseOutlineSignInRedirectPage, LoginPage
|
||||
from common.test.acceptance.pages.studio.overview import CourseOutlinePage
|
||||
from common.test.acceptance.pages.studio.settings import SettingsPage
|
||||
from common.test.acceptance.pages.studio.settings_advanced import AdvancedSettingsPage
|
||||
from common.test.acceptance.pages.studio.settings_graders import GradingPage
|
||||
from common.test.acceptance.pages.studio.signup import SignupPage
|
||||
from common.test.acceptance.pages.studio.textbook_upload import TextbookUploadPage
|
||||
from common.test.acceptance.pages.studio.users import CourseTeamPage
|
||||
from common.test.acceptance.tests.helpers import AcceptanceTest, UniqueCourseTest
|
||||
|
||||
from .base_studio_test import StudioCourseTest
|
||||
|
||||
|
||||
class LoggedOutTest(AcceptanceTest):
|
||||
"""
|
||||
Smoke test for pages in Studio that are visible when logged out.
|
||||
"""
|
||||
shard = 21
|
||||
|
||||
def setUp(self):
|
||||
super(LoggedOutTest, self).setUp()
|
||||
self.pages = [LoginPage(self.browser), IndexPage(self.browser), SignupPage(self.browser),
|
||||
AccessibilityPage(self.browser)]
|
||||
|
||||
def test_page_existence(self):
|
||||
"""
|
||||
Make sure that all the pages are accessible.
|
||||
Rather than fire up the browser just to check each url,
|
||||
do them all sequentially in this testcase.
|
||||
"""
|
||||
for page in self.pages:
|
||||
page.visit()
|
||||
|
||||
|
||||
class LoggedInPagesTest(AcceptanceTest):
|
||||
"""
|
||||
Verify the pages in Studio that you can get to when logged in and do not have a course yet.
|
||||
"""
|
||||
shard = 21
|
||||
|
||||
def setUp(self):
|
||||
super(LoggedInPagesTest, self).setUp()
|
||||
self.auth_page = AutoAuthPage(self.browser, staff=True)
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
self.home_page = HomePage(self.browser)
|
||||
|
||||
def test_logged_in_no_courses(self):
|
||||
"""
|
||||
Make sure that you can get to the dashboard and home pages without a course.
|
||||
"""
|
||||
self.auth_page.visit()
|
||||
self.dashboard_page.visit()
|
||||
self.home_page.visit()
|
||||
|
||||
|
||||
class SignUpAndSignInTest(UniqueCourseTest):
|
||||
"""
|
||||
Test studio sign-up and sign-in
|
||||
"""
|
||||
shard = 21
|
||||
|
||||
def setUp(self):
|
||||
super(SignUpAndSignInTest, self).setUp()
|
||||
self.sign_up_page = SignupPage(self.browser)
|
||||
self.login_page = LoginPage(self.browser)
|
||||
|
||||
self.course_outline_page = CourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
self.course_outline_sign_in_redirect_page = CourseOutlineSignInRedirectPage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
self.course_fixture = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name'],
|
||||
)
|
||||
self.user = None
|
||||
|
||||
def install_course_fixture(self):
|
||||
"""
|
||||
Install a course fixture
|
||||
"""
|
||||
self.course_fixture.install()
|
||||
self.user = self.course_fixture.user
|
||||
|
||||
def test_sign_up_from_home(self):
|
||||
"""
|
||||
Scenario: Sign up from the homepage
|
||||
Given I visit the Studio homepage
|
||||
When I click the link with the text "Sign Up"
|
||||
And I fill in the registration form
|
||||
And I press the Create My Account button on the registration form
|
||||
Then I should see an email verification prompt
|
||||
"""
|
||||
index_page = IndexPage(self.browser)
|
||||
index_page.visit()
|
||||
index_page.click_sign_up()
|
||||
|
||||
# Register the user.
|
||||
unique_number = uuid.uuid4().hex[:4]
|
||||
self.sign_up_page.sign_up_user(
|
||||
'{}-email@host.com'.format(unique_number),
|
||||
'{}-name'.format(unique_number),
|
||||
'{}-username'.format(unique_number),
|
||||
'{}-password'.format(unique_number),
|
||||
)
|
||||
home = HomePage(self.browser)
|
||||
home.wait_for_page()
|
||||
|
||||
def test_sign_up_with_bad_password(self):
|
||||
"""
|
||||
Scenario: Sign up from the homepage
|
||||
Given I visit the Studio homepage
|
||||
When I click the link with the text "Sign Up"
|
||||
And I fill in the registration form
|
||||
When I enter an insufficient password and focus out
|
||||
I should see an error message
|
||||
"""
|
||||
index_page = IndexPage(self.browser)
|
||||
index_page.visit()
|
||||
index_page.click_sign_up()
|
||||
|
||||
password_input = self.sign_up_page.input_password('a') # Arbitrary short password that will fail
|
||||
password_input.send_keys(Keys.TAB) # Focus out of the element
|
||||
index_page.wait_for_element_visibility('#register-password-validation-error', 'Password Error Message')
|
||||
self.assertIsNotNone(index_page.q(css='#register-password-validation-error-msg')) # Error message should exist
|
||||
|
||||
def test_login_with_valid_redirect(self):
|
||||
"""
|
||||
Scenario: Login with a valid redirect
|
||||
Given I have opened a new course in Studio
|
||||
And I am not logged in
|
||||
And I visit the url "/course/slashes:MITx+999+Robot_Super_Course"
|
||||
And I should see the path is "/signin_redirect_to_lms?next=/course/slashes%3AMITx%2B999%2BRobot_Super_Course"
|
||||
When I fill in and submit the LMS login form
|
||||
Then I should see that the path is "/course/slashes:MITx+999+Robot_Super_Course"
|
||||
"""
|
||||
self.install_course_fixture()
|
||||
# Get the url, browser should land here after sign in.
|
||||
course_url = self.course_outline_sign_in_redirect_page.url
|
||||
self.course_outline_sign_in_redirect_page.visit()
|
||||
# Login
|
||||
self.course_outline_sign_in_redirect_page.login(self.user['email'], self.user['password'])
|
||||
self.course_outline_page.wait_for_page()
|
||||
# Verify that correct course is displayed after sign in.
|
||||
self.assertEqual(self.browser.current_url, course_url)
|
||||
|
||||
|
||||
class CoursePagesTest(StudioCourseTest):
|
||||
"""
|
||||
Tests that verify the pages in Studio that you can get to when logged
|
||||
in and have a course.
|
||||
"""
|
||||
shard = 21
|
||||
COURSE_ID_SEPARATOR = "."
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Install a course with no content using a fixture.
|
||||
"""
|
||||
super(CoursePagesTest, self).setUp()
|
||||
|
||||
self.pages = [
|
||||
clz(self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run'])
|
||||
for clz in [
|
||||
AssetIndexPageStudioFrontend,
|
||||
CourseUpdatesPage,
|
||||
PagesPage, ExportCoursePage, ImportCoursePage, CourseTeamPage, CourseOutlinePage, SettingsPage,
|
||||
AdvancedSettingsPage, GradingPage, TextbookUploadPage
|
||||
]
|
||||
]
|
||||
|
||||
def test_page_redirect(self):
|
||||
"""
|
||||
/course/ is the base URL for all courses, but by itself, it should
|
||||
redirect to /home/.
|
||||
"""
|
||||
self.dashboard_page = DashboardPage(self.browser) # pylint: disable=attribute-defined-outside-init
|
||||
self.dashboard_page.visit()
|
||||
self.assertEqual(self.browser.current_url.strip('/').rsplit('/')[-1], 'home')
|
||||
|
||||
def test_page_existence(self):
|
||||
"""
|
||||
Make sure that all these pages are accessible once you have a course.
|
||||
Rather than fire up the browser just to check each url,
|
||||
do them all sequentially in this testcase.
|
||||
"""
|
||||
|
||||
# In the real workflow you will be at the dashboard page
|
||||
# after you log in. This test was intermittently failing on the
|
||||
# first (asset) page load with a 404.
|
||||
# Not exactly sure why, so adding in a visit
|
||||
# to the dashboard page here to replicate the usual flow.
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
self.dashboard_page.visit()
|
||||
|
||||
# Verify that each page is available
|
||||
for page in self.pages:
|
||||
page.visit()
|
||||
|
||||
|
||||
class DiscussionPreviewTest(StudioCourseTest):
|
||||
"""
|
||||
Tests that Inline Discussions are rendered with a custom preview in Studio
|
||||
"""
|
||||
shard = 21
|
||||
|
||||
def setUp(self):
|
||||
super(DiscussionPreviewTest, self).setUp()
|
||||
cop = CourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
cop.visit()
|
||||
self.unit = cop.section('Test Section').subsection('Test Subsection').expand_subsection().unit('Test Unit')
|
||||
self.unit.go_to()
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Return a test course fixture containing a discussion component.
|
||||
"""
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc("chapter", "Test Section").add_children(
|
||||
XBlockFixtureDesc("sequential", "Test Subsection").add_children(
|
||||
XBlockFixtureDesc("vertical", "Test Unit").add_children(
|
||||
XBlockFixtureDesc(
|
||||
"discussion",
|
||||
"Test Discussion",
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_is_preview(self):
|
||||
"""
|
||||
Ensure that the preview version of the discussion is rendered.
|
||||
"""
|
||||
self.assertTrue(self.unit.q(css=".discussion-preview").present)
|
||||
self.assertFalse(self.unit.q(css=".discussion-show").present)
|
||||
@@ -1,248 +0,0 @@
|
||||
"""
|
||||
Acceptance tests for grade settings in Studio.
|
||||
"""
|
||||
|
||||
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from six.moves import range
|
||||
|
||||
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.studio.settings_graders import GradingPage
|
||||
from common.test.acceptance.tests.studio.base_studio_test import StudioCourseTest
|
||||
|
||||
|
||||
class GradingPageTest(StudioCourseTest):
|
||||
"""
|
||||
Bockchoy tests to add/edit grade settings in studio.
|
||||
"""
|
||||
shard = 23
|
||||
|
||||
url = None
|
||||
GRACE_FIELD_CSS = "#course-grading-graceperiod"
|
||||
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
super(GradingPageTest, self).setUp()
|
||||
self.grading_page = GradingPage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
self.grading_page.visit()
|
||||
self.ensure_input_fields_are_loaded()
|
||||
|
||||
def ensure_input_fields_are_loaded(self):
|
||||
"""
|
||||
Ensures values in input fields are loaded.
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: self.grading_page.q(css=self.GRACE_FIELD_CSS).attrs('value')[0],
|
||||
"Waiting for input fields to be loaded"
|
||||
).fulfill()
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Return a test course fixture.
|
||||
"""
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc("chapter", "Test Section").add_children(
|
||||
XBlockFixtureDesc("sequential", "Test Subsection").add_children(
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_add_grade_range(self):
|
||||
"""
|
||||
Scenario: Users can add grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add "1" new grade
|
||||
Then I see I now have "3"
|
||||
"""
|
||||
length = self.grading_page.total_number_of_grades
|
||||
self.grading_page.click_add_grade()
|
||||
self.assertTrue(self.grading_page.is_grade_added(length))
|
||||
self.grading_page.save()
|
||||
self.grading_page.refresh_and_wait_for_load()
|
||||
total_number_of_grades = self.grading_page.total_number_of_grades
|
||||
self.assertEqual(total_number_of_grades, 3)
|
||||
|
||||
def test_staff_can_add_up_to_five_grades_only(self):
|
||||
"""
|
||||
Scenario: Users can only have up to 5 grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I try to add more than 5 grades
|
||||
Then I see I have only "5" grades
|
||||
"""
|
||||
for grade_ordinal in range(1, 5):
|
||||
length = self.grading_page.total_number_of_grades
|
||||
self.grading_page.click_add_grade()
|
||||
# By default page has 2 grades, so greater than 3 means, attempt is made to add 6th grade
|
||||
if grade_ordinal > 3:
|
||||
self.assertFalse(self.grading_page.is_grade_added(length))
|
||||
else:
|
||||
self.assertTrue(self.grading_page.is_grade_added(length))
|
||||
self.grading_page.save()
|
||||
self.grading_page.refresh_and_wait_for_load()
|
||||
total_number_of_grades = self.grading_page.total_number_of_grades
|
||||
self.assertEqual(total_number_of_grades, 5)
|
||||
|
||||
def test_grades_remain_consistent(self):
|
||||
"""
|
||||
Scenario: When user removes a grade the remaining grades should be consistent
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add "2" new grade
|
||||
Then Grade list has "A,B,C,F" grades
|
||||
And I delete a grade
|
||||
Then Grade list has "A,B,F" grades
|
||||
"""
|
||||
for _ in range(2):
|
||||
length = self.grading_page.total_number_of_grades
|
||||
self.grading_page.click_add_grade()
|
||||
self.assertTrue(self.grading_page.is_grade_added(length))
|
||||
self.grading_page.save()
|
||||
grades_alphabets = self.grading_page.grade_letters
|
||||
self.assertEqual(grades_alphabets, ['A', 'B', 'C', 'F'])
|
||||
self.grading_page.remove_grades(1)
|
||||
self.grading_page.save()
|
||||
grades_alphabets = self.grading_page.grade_letters
|
||||
self.assertEqual(grades_alphabets, ['A', 'B', 'F'])
|
||||
|
||||
def test_staff_can_delete_grade_range(self):
|
||||
"""
|
||||
Scenario: Users can delete grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add "1" new grade
|
||||
And I delete a grade
|
||||
Then I see I now have "2" grades
|
||||
"""
|
||||
length = self.grading_page.total_number_of_grades
|
||||
self.grading_page.click_add_grade()
|
||||
self.assertTrue(self.grading_page.is_grade_added(length))
|
||||
self.grading_page.save()
|
||||
total_number_of_grades = self.grading_page.total_number_of_grades
|
||||
self.assertEqual(total_number_of_grades, 3)
|
||||
self.grading_page.remove_grades(1)
|
||||
total_number_of_grades = self.grading_page.total_number_of_grades
|
||||
self.assertEqual(total_number_of_grades, 2)
|
||||
|
||||
def test_staff_can_move_grading_ranges(self):
|
||||
"""
|
||||
Scenario: Users can move grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I move a grading section
|
||||
Then I see that the grade range has changed
|
||||
"""
|
||||
grade_ranges = self.grading_page.grades_range
|
||||
self.assertIn('0-50', grade_ranges)
|
||||
self.grading_page.drag_and_drop_grade()
|
||||
grade_ranges = self.grading_page.grades_range
|
||||
self.assertIn(
|
||||
'0-3',
|
||||
grade_ranges,
|
||||
u'expected range: 0-3, not found in grade ranges:{}'.format(grade_ranges)
|
||||
)
|
||||
|
||||
def test_settings_are_persisted_on_save_only(self):
|
||||
"""
|
||||
Scenario: Settings are only persisted when saved
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
Then I do not see the changes persisted on refresh
|
||||
"""
|
||||
self.grading_page.change_assignment_name('Homework', 'New Type')
|
||||
self.grading_page.refresh_and_wait_for_load()
|
||||
self.assertIn('Homework', self.grading_page.get_assignment_names)
|
||||
|
||||
def test_settings_are_reset_on_cancel(self):
|
||||
"""
|
||||
Scenario: Settings are reset on cancel
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
And I press the "Cancel" notification button
|
||||
Then I see the assignment type "Homework"
|
||||
"""
|
||||
self.grading_page.change_assignment_name('Homework', 'New Type')
|
||||
self.grading_page.cancel()
|
||||
assignment_names = self.grading_page.get_assignment_names
|
||||
self.assertIn('Homework', assignment_names)
|
||||
|
||||
def test_confirmation_is_shown_on_save(self):
|
||||
"""
|
||||
Scenario: Confirmation is shown on save
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
And I press the "Save" notification button
|
||||
Then I see a confirmation that my changes have been saved
|
||||
"""
|
||||
self.grading_page.change_assignment_name('Homework', 'New Type')
|
||||
self.grading_page.save()
|
||||
confirmation_message = self.grading_page.confirmation_message
|
||||
self.assertEqual(confirmation_message, 'Your changes have been saved.')
|
||||
|
||||
def test_staff_can_set_weight_to_assignment(self):
|
||||
"""
|
||||
Scenario: Users can set weight to Assignment types
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add a new assignment type "New Type"
|
||||
And I set the assignment weight to "7"
|
||||
And I press the "Save" notification button
|
||||
Then the assignment weight is displayed as "7"
|
||||
And I reload the page
|
||||
Then the assignment weight is displayed as "7"
|
||||
"""
|
||||
self.grading_page.add_new_assignment_type()
|
||||
self.grading_page.change_assignment_name('', 'New Type')
|
||||
self.grading_page.set_weight('New Type', '7')
|
||||
self.grading_page.save()
|
||||
assignment_weight = self.grading_page.get_assignment_weight('New Type')
|
||||
self.assertEqual(assignment_weight, '7')
|
||||
self.grading_page.refresh_and_wait_for_load()
|
||||
assignment_weight = self.grading_page.get_assignment_weight('New Type')
|
||||
self.assertEqual(assignment_weight, '7')
|
||||
|
||||
def test_staff_cannot_save_invalid_settings(self):
|
||||
"""
|
||||
Scenario: User cannot save invalid settings
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to ""
|
||||
Then the save notification button is disabled
|
||||
"""
|
||||
self.grading_page.change_assignment_name('Homework', '')
|
||||
self.assertTrue(self.grading_page.is_notification_button_disbaled(), True)
|
||||
|
||||
def test_edit_highest_grade_name(self):
|
||||
"""
|
||||
Scenario: User can edit grading range names
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I change the highest grade range to "Good"
|
||||
And I press the "Save" notification button
|
||||
And I reload the page
|
||||
Then I see the highest grade range is "Good"
|
||||
"""
|
||||
self.grading_page.edit_grade_name('Good')
|
||||
self.grading_page.save()
|
||||
self.grading_page.refresh_and_wait_for_load()
|
||||
grade_name = self.grading_page.highest_grade_name
|
||||
self.assertEqual(grade_name, 'Good')
|
||||
|
||||
def test_staff_cannot_edit_lowest_grade_name(self):
|
||||
"""
|
||||
Scenario: User cannot edit failing grade range name
|
||||
Given I have populated a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
Then I cannot edit the "Fail" grade range
|
||||
"""
|
||||
self.grading_page.try_edit_fail_grade('Failure')
|
||||
self.assertNotEqual(self.grading_page.lowest_grade_name, 'Failure')
|
||||
@@ -1,85 +0,0 @@
|
||||
"""
|
||||
Acceptance tests for Home Page (My Courses / My Libraries).
|
||||
"""
|
||||
|
||||
|
||||
import datetime
|
||||
|
||||
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.lms.account_settings import AccountSettingsPage
|
||||
from common.test.acceptance.pages.studio.index import DashboardPage
|
||||
from common.test.acceptance.tests.helpers import AcceptanceTest, get_selected_option_text, select_option_by_text
|
||||
|
||||
from .base_studio_test import StudioCourseTest
|
||||
|
||||
|
||||
class CreateLibraryTest(AcceptanceTest):
|
||||
"""
|
||||
Test that we can create a new content library on the studio home page.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Load the helper for the home page (dashboard page)
|
||||
"""
|
||||
super(CreateLibraryTest, self).setUp()
|
||||
|
||||
self.auth_page = AutoAuthPage(self.browser, staff=True)
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
|
||||
|
||||
class StudioLanguageTest(AcceptanceTest):
|
||||
""" Test suite for the Studio Language """
|
||||
shard = 21
|
||||
|
||||
def setUp(self):
|
||||
super(StudioLanguageTest, self).setUp()
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
self.account_settings = AccountSettingsPage(self.browser)
|
||||
AutoAuthPage(self.browser).visit()
|
||||
|
||||
def test_studio_language_change(self):
|
||||
"""
|
||||
Scenario: Ensure that language selection is working fine.
|
||||
First I go to the user dashboard page in studio. I can see 'English' is selected by default.
|
||||
Then I choose 'Dummy Language' from drop down (at top of the page).
|
||||
Then I visit the student account settings page and I can see the language has been updated to 'Dummy Language'
|
||||
in both drop downs.
|
||||
"""
|
||||
dummy_language = u'Dummy Language (Esperanto)'
|
||||
self.dashboard_page.visit()
|
||||
language_selector = self.dashboard_page.language_selector
|
||||
self.assertEqual(
|
||||
get_selected_option_text(language_selector),
|
||||
u'English'
|
||||
)
|
||||
|
||||
select_option_by_text(language_selector, dummy_language)
|
||||
self.dashboard_page.wait_for_ajax()
|
||||
self.account_settings.visit()
|
||||
self.assertEqual(self.account_settings.value_for_dropdown_field('pref-lang'), dummy_language)
|
||||
self.assertEqual(
|
||||
get_selected_option_text(language_selector),
|
||||
u'Dummy Language (Esperanto)'
|
||||
)
|
||||
|
||||
|
||||
class ArchivedCourseTest(StudioCourseTest):
|
||||
""" Tests that archived courses appear in their own list. """
|
||||
|
||||
def setUp(self, is_staff=True, test_xss=False):
|
||||
"""
|
||||
Load the helper for the home page (dashboard page)
|
||||
"""
|
||||
super(ArchivedCourseTest, self).setUp(is_staff=is_staff, test_xss=test_xss)
|
||||
self.dashboard_page = DashboardPage(self.browser)
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
current_time = datetime.datetime.now()
|
||||
course_start_date = current_time - datetime.timedelta(days=60)
|
||||
course_end_date = current_time - datetime.timedelta(days=90)
|
||||
|
||||
course_fixture.add_course_details({
|
||||
'start_date': course_start_date,
|
||||
'end_date': course_end_date
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user