Merge pull request #6459 from edx/content-libraries

Content libraries MVP
This commit is contained in:
Braden MacDonald
2015-01-13 11:20:20 -08:00
132 changed files with 9358 additions and 1409 deletions

View File

@@ -0,0 +1,196 @@
"""
Common code shared by course and library fixtures.
"""
import re
import requests
import json
from lazy import lazy
from . import STUDIO_BASE_URL
class StudioApiLoginError(Exception):
"""
Error occurred while logging in to the Studio API.
"""
pass
class StudioApiFixture(object):
"""
Base class for fixtures that use the Studio restful API.
"""
def __init__(self):
# Info about the auto-auth user used to create the course/library.
self.user = {}
@lazy
def session(self):
"""
Log in as a staff user, then return a `requests` `session` object for the logged in user.
Raises a `StudioApiLoginError` if the login fails.
"""
# Use auto-auth to retrieve the session for a logged in user
session = requests.Session()
response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true")
# Return the session from the request
if response.ok:
# auto_auth returns information about the newly created user
# capture this so it can be used by by the testcases.
user_pattern = re.compile(r'Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
r'(?P<username>\S+)', r'(?P<email>[^\)]+)', r'(?P<password>\S+)', r'(?P<user_id>\d+)'))
user_matches = re.match(user_pattern, response.text)
if user_matches:
self.user = user_matches.groupdict()
return session
else:
msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code)
raise StudioApiLoginError(msg)
@lazy
def session_cookies(self):
"""
Log in as a staff user, then return the cookies for the session (as a dict)
Raises a `StudioApiLoginError` if the login fails.
"""
return {key: val for key, val in self.session.cookies.items()}
@lazy
def headers(self):
"""
Default HTTP headers dict.
"""
return {
'Content-type': 'application/json',
'Accept': 'application/json',
'X-CSRFToken': self.session_cookies.get('csrftoken', '')
}
class FixtureError(Exception):
"""
Error occurred while installing a course or library fixture.
"""
pass
class XBlockContainerFixture(StudioApiFixture):
"""
Base class for course and library fixtures.
"""
def __init__(self):
self.children = []
super(XBlockContainerFixture, self).__init__()
def add_children(self, *args):
"""
Add children XBlock to the container.
Each item in `args` is an `XBlockFixtureDesc` object.
Returns the fixture to allow chaining.
"""
self.children.extend(args)
return self
def _create_xblock_children(self, parent_loc, xblock_descriptions):
"""
Recursively create XBlock children.
"""
for desc in xblock_descriptions:
loc = self.create_xblock(parent_loc, desc)
self._create_xblock_children(loc, desc.children)
def create_xblock(self, parent_loc, xblock_desc):
"""
Create an XBlock with `parent_loc` (the location of the parent block)
and `xblock_desc` (an `XBlockFixtureDesc` instance).
"""
create_payload = {
'category': xblock_desc.category,
'display_name': xblock_desc.display_name,
}
if parent_loc is not None:
create_payload['parent_locator'] = parent_loc
# Create the new XBlock
response = self.session.post(
STUDIO_BASE_URL + '/xblock/',
data=json.dumps(create_payload),
headers=self.headers,
)
if not response.ok:
msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code)
raise FixtureError(msg)
try:
loc = response.json().get('locator')
xblock_desc.locator = loc
except ValueError:
raise FixtureError("Could not decode JSON from '{0}'".format(response.content))
# Configure the XBlock
response = self.session.post(
STUDIO_BASE_URL + '/xblock/' + loc,
data=xblock_desc.serialize(),
headers=self.headers,
)
if response.ok:
return loc
else:
raise FixtureError("Could not update {0}. Status code: {1}".format(xblock_desc, response.status_code))
def _update_xblock(self, locator, data):
"""
Update the xblock at `locator`.
"""
# Create the new XBlock
response = self.session.put(
"{}/xblock/{}".format(STUDIO_BASE_URL, locator),
data=json.dumps(data),
headers=self.headers,
)
if not response.ok:
msg = "Could not update {} with data {}. Status was {}".format(locator, data, response.status_code)
raise FixtureError(msg)
def _encode_post_dict(self, post_dict):
"""
Encode `post_dict` (a dictionary) as UTF-8 encoded JSON.
"""
return json.dumps({
k: v.encode('utf-8') if isinstance(v, basestring) else v
for k, v in post_dict.items()
})
def get_nested_xblocks(self, category=None):
"""
Return a list of nested XBlocks for the container that can be filtered by
category.
"""
xblocks = self._get_nested_xblocks(self)
if category:
xblocks = [x for x in xblocks if x.category == category]
return xblocks
def _get_nested_xblocks(self, xblock_descriptor):
"""
Return a list of nested XBlocks for the container.
"""
xblocks = list(xblock_descriptor.children)
for child in xblock_descriptor.children:
xblocks.extend(self._get_nested_xblocks(child))
return xblocks
def _publish_xblock(self, locator):
"""
Publish the xblock at `locator`.
"""
self._update_xblock(locator, {'publish': 'make_public'})

View File

@@ -4,77 +4,17 @@ Fixture to create a course and course components (XBlocks).
import mimetypes
import json
import re
import datetime
import requests
from textwrap import dedent
from collections import namedtuple
from path import path
from lazy import lazy
from opaque_keys.edx.keys import CourseKey
from . import STUDIO_BASE_URL
class StudioApiLoginError(Exception):
"""
Error occurred while logging in to the Studio API.
"""
pass
class StudioApiFixture(object):
"""
Base class for fixtures that use the Studio restful API.
"""
def __init__(self):
# Info about the auto-auth user used to create the course.
self.user = {}
@lazy
def session(self):
"""
Log in as a staff user, then return a `requests` `session` object for the logged in user.
Raises a `StudioApiLoginError` if the login fails.
"""
# Use auto-auth to retrieve the session for a logged in user
session = requests.Session()
response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true")
# Return the session from the request
if response.ok:
# auto_auth returns information about the newly created user
# capture this so it can be used by by the testcases.
user_pattern = re.compile('Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
'(?P<username>\S+)', '(?P<email>[^\)]+)', '(?P<password>\S+)', '(?P<user_id>\d+)'))
user_matches = re.match(user_pattern, response.text)
if user_matches:
self.user = user_matches.groupdict()
return session
else:
msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code)
raise StudioApiLoginError(msg)
@lazy
def session_cookies(self):
"""
Log in as a staff user, then return the cookies for the session (as a dict)
Raises a `StudioApiLoginError` if the login fails.
"""
return {key: val for key, val in self.session.cookies.items()}
@lazy
def headers(self):
"""
Default HTTP headers dict.
"""
return {
'Content-type': 'application/json',
'Accept': 'application/json',
'X-CSRFToken': self.session_cookies.get('csrftoken', '')
}
from .base import XBlockContainerFixture, FixtureError
class XBlockFixtureDesc(object):
@@ -105,7 +45,7 @@ class XBlockFixtureDesc(object):
def add_children(self, *args):
"""
Add child XBlocks to this XBlock.
Each item in `args` is an `XBlockFixtureDescriptor` object.
Each item in `args` is an `XBlockFixtureDesc` object.
Returns the `xblock_desc` instance to allow chaining.
"""
@@ -154,14 +94,7 @@ class XBlockFixtureDesc(object):
CourseUpdateDesc = namedtuple("CourseUpdateDesc", ['date', 'content'])
class CourseFixtureError(Exception):
"""
Error occurred while installing a course fixture.
"""
pass
class CourseFixture(StudioApiFixture):
class CourseFixture(XBlockContainerFixture):
"""
Fixture for ensuring that a course exists.
@@ -181,6 +114,7 @@ class CourseFixture(StudioApiFixture):
These have the same meaning as in the Studio restful API /course end-point.
"""
super(CourseFixture, self).__init__()
self._course_dict = {
'org': org,
'number': number,
@@ -202,7 +136,6 @@ class CourseFixture(StudioApiFixture):
self._updates = []
self._handouts = []
self.children = []
self._assets = []
self._advanced_settings = {}
self._course_key = None
@@ -213,16 +146,6 @@ class CourseFixture(StudioApiFixture):
"""
return "<CourseFixture: org='{org}', number='{number}', run='{run}'>".format(**self._course_dict)
def add_children(self, *args):
"""
Add children XBlock to the course.
Each item in `args` is an `XBlockFixtureDescriptor` object.
Returns the course fixture to allow chaining.
"""
self.children.extend(args)
return self
def add_update(self, update):
"""
Add an update to the course. `update` should be a `CourseUpdateDesc`.
@@ -252,7 +175,7 @@ class CourseFixture(StudioApiFixture):
"""
Create the course and XBlocks within the course.
This is NOT an idempotent method; if the course already exists, this will
raise a `CourseFixtureError`. You should use unique course identifiers to avoid
raise a `FixtureError`. You should use unique course identifiers to avoid
conflicts between tests.
"""
self._create_course()
@@ -308,18 +231,18 @@ class CourseFixture(StudioApiFixture):
err = response.json().get('ErrMsg')
except ValueError:
raise CourseFixtureError(
raise FixtureError(
"Could not parse response from course request as JSON: '{0}'".format(
response.content))
# This will occur if the course identifier is not unique
if err is not None:
raise CourseFixtureError("Could not create course {0}. Error message: '{1}'".format(self, err))
raise FixtureError("Could not create course {0}. Error message: '{1}'".format(self, err))
if response.ok:
self._course_key = response.json()['course_key']
else:
raise CourseFixtureError(
raise FixtureError(
"Could not create course {0}. Status was {1}".format(
self._course_dict, response.status_code))
@@ -333,14 +256,14 @@ class CourseFixture(StudioApiFixture):
response = self.session.get(url, headers=self.headers)
if not response.ok:
raise CourseFixtureError(
raise FixtureError(
"Could not retrieve course details. Status was {0}".format(
response.status_code))
try:
details = response.json()
except ValueError:
raise CourseFixtureError(
raise FixtureError(
"Could not decode course details as JSON: '{0}'".format(details)
)
@@ -354,7 +277,7 @@ class CourseFixture(StudioApiFixture):
)
if not response.ok:
raise CourseFixtureError(
raise FixtureError(
"Could not update course details to '{0}' with {1}: Status was {2}.".format(
self._course_details, url, response.status_code))
@@ -382,7 +305,7 @@ class CourseFixture(StudioApiFixture):
response = self.session.post(url, data=payload, headers=self.headers)
if not response.ok:
raise CourseFixtureError(
raise FixtureError(
"Could not update course handouts with {0}. Status was {1}".format(url, response.status_code))
def _install_course_updates(self):
@@ -399,14 +322,14 @@ class CourseFixture(StudioApiFixture):
response = self.session.post(url, headers=self.headers, data=payload)
if not response.ok:
raise CourseFixtureError(
raise FixtureError(
"Could not add update to course: {0} with {1}. Status was {2}".format(
update, url, response.status_code))
def _upload_assets(self):
"""
Upload assets
:raise CourseFixtureError:
:raise FixtureError:
"""
url = STUDIO_BASE_URL + self._assets_url
@@ -426,7 +349,7 @@ class CourseFixture(StudioApiFixture):
upload_response = self.session.post(url, files=files, headers=headers)
if not upload_response.ok:
raise CourseFixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format(
raise FixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format(
asset_name=asset_name, url=url, code=upload_response.status_code))
def _add_advanced_settings(self):
@@ -442,7 +365,7 @@ class CourseFixture(StudioApiFixture):
)
if not response.ok:
raise CourseFixtureError(
raise FixtureError(
"Could not update advanced details to '{0}' with {1}: Status was {2}.".format(
self._advanced_settings, url, response.status_code))
@@ -450,101 +373,5 @@ class CourseFixture(StudioApiFixture):
"""
Recursively create XBlock children.
"""
for desc in xblock_descriptions:
loc = self.create_xblock(parent_loc, desc)
self._create_xblock_children(loc, desc.children)
super(CourseFixture, self)._create_xblock_children(parent_loc, xblock_descriptions)
self._publish_xblock(parent_loc)
def get_nested_xblocks(self, category=None):
"""
Return a list of nested XBlocks for the course that can be filtered by
category.
"""
xblocks = self._get_nested_xblocks(self)
if category:
xblocks = filter(lambda x: x.category == category, xblocks)
return xblocks
def _get_nested_xblocks(self, xblock_descriptor):
"""
Return a list of nested XBlocks for the course.
"""
xblocks = list(xblock_descriptor.children)
for child in xblock_descriptor.children:
xblocks.extend(self._get_nested_xblocks(child))
return xblocks
def create_xblock(self, parent_loc, xblock_desc):
"""
Create an XBlock with `parent_loc` (the location of the parent block)
and `xblock_desc` (an `XBlockFixtureDesc` instance).
"""
create_payload = {
'category': xblock_desc.category,
'display_name': xblock_desc.display_name,
}
if parent_loc is not None:
create_payload['parent_locator'] = parent_loc
# Create the new XBlock
response = self.session.post(
STUDIO_BASE_URL + '/xblock/',
data=json.dumps(create_payload),
headers=self.headers,
)
if not response.ok:
msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code)
raise CourseFixtureError(msg)
try:
loc = response.json().get('locator')
xblock_desc.locator = loc
except ValueError:
raise CourseFixtureError("Could not decode JSON from '{0}'".format(response.content))
# Configure the XBlock
response = self.session.post(
STUDIO_BASE_URL + '/xblock/' + loc,
data=xblock_desc.serialize(),
headers=self.headers,
)
if response.ok:
return loc
else:
raise CourseFixtureError(
"Could not update {0}. Status code: {1}".format(
xblock_desc, response.status_code))
def _publish_xblock(self, locator):
"""
Publish the xblock at `locator`.
"""
self._update_xblock(locator, {'publish': 'make_public'})
def _update_xblock(self, locator, data):
"""
Update the xblock at `locator`.
"""
# Create the new XBlock
response = self.session.put(
"{}/xblock/{}".format(STUDIO_BASE_URL, locator),
data=json.dumps(data),
headers=self.headers,
)
if not response.ok:
msg = "Could not update {} with data {}. Status was {}".format(locator, data, response.status_code)
raise CourseFixtureError(msg)
def _encode_post_dict(self, post_dict):
"""
Encode `post_dict` (a dictionary) as UTF-8 encoded JSON.
"""
return json.dumps({
k: v.encode('utf-8') if isinstance(v, basestring) else v
for k, v in post_dict.items()
})

View File

@@ -0,0 +1,93 @@
"""
Fixture to create a Content Library
"""
from opaque_keys.edx.keys import CourseKey
from . import STUDIO_BASE_URL
from .base import XBlockContainerFixture, FixtureError
class LibraryFixture(XBlockContainerFixture):
"""
Fixture for ensuring that a library exists.
WARNING: This fixture is NOT idempotent. To avoid conflicts
between tests, you should use unique library identifiers for each fixture.
"""
def __init__(self, org, number, display_name):
"""
Configure the library fixture to create a library with
"""
super(LibraryFixture, self).__init__()
self.library_info = {
'org': org,
'number': number,
'display_name': display_name
}
self.display_name = display_name
self._library_key = None
super(LibraryFixture, self).__init__()
def __str__(self):
"""
String representation of the library fixture, useful for debugging.
"""
return "<LibraryFixture: org='{org}', number='{number}'>".format(**self.library_info)
def install(self):
"""
Create the library and XBlocks within the library.
This is NOT an idempotent method; if the library already exists, this will
raise a `FixtureError`. You should use unique library identifiers to avoid
conflicts between tests.
"""
self._create_library()
self._create_xblock_children(self.library_location, self.children)
return self
@property
def library_key(self):
"""
Get the LibraryLocator for this library, as a string.
"""
return self._library_key
@property
def library_location(self):
"""
Return the locator string for the LibraryRoot XBlock that is the root of the library hierarchy.
"""
lib_key = CourseKey.from_string(self._library_key)
return unicode(lib_key.make_usage_key('library', 'library'))
def _create_library(self):
"""
Create the library described in the fixture.
Will fail if the library already exists.
"""
response = self.session.post(
STUDIO_BASE_URL + '/library/',
data=self._encode_post_dict(self.library_info),
headers=self.headers
)
if response.ok:
self._library_key = response.json()['library_key']
else:
try:
err_msg = response.json().get('ErrMsg')
except ValueError:
err_msg = "Unknown Error"
raise FixtureError("Could not create library {}. Status was {}, error was: {}".format(
self.library_info, response.status_code, err_msg
))
def create_xblock(self, parent_loc, xblock_desc):
# Disable publishing for library XBlocks:
xblock_desc.publish = "not-applicable"
return super(LibraryFixture, self).create_xblock(parent_loc, xblock_desc)

View File

@@ -0,0 +1,48 @@
"""
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 '{}[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] h2.problem-header"))
return frozenset(child.text for child in child_blocks_headers)

View File

@@ -246,6 +246,7 @@ class CombinedLoginAndRegisterPage(PageObject):
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
return (bool(errors), errors)
return Promise(_check_func, "Errors are visible").fulfill()
@@ -259,6 +260,7 @@ class CombinedLoginAndRegisterPage(PageObject):
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()

View File

@@ -15,7 +15,8 @@ class AutoAuthPage(PageObject):
this url will create a user and log them in.
"""
def __init__(self, browser, username=None, email=None, password=None, staff=None, course_id=None, roles=None):
def __init__(self, browser, username=None, email=None, password=None,
staff=None, course_id=None, roles=None, no_login=None):
"""
Auto-auth is an end-point for HTTP GET requests.
By default, it will create accounts with random user credentials,
@@ -51,6 +52,9 @@ class AutoAuthPage(PageObject):
if roles is not None:
self._params['roles'] = roles
if no_login:
self._params['no_login'] = True
@property
def url(self):
"""
@@ -66,7 +70,7 @@ class AutoAuthPage(PageObject):
def is_browser_on_page(self):
message = self.q(css='BODY').text[0]
match = re.search(r'Logged in user ([^$]+) with password ([^$]+) and user_id ([^$]+)$', message)
match = re.search(r'(Logged in|Created) user ([^$]+) with password ([^$]+) and user_id ([^$]+)$', message)
return True if match else False
def get_user_id(self):

View File

@@ -6,7 +6,7 @@ from bok_choy.page_object import PageObject
from bok_choy.promise import Promise, EmptyPromise
from . import BASE_URL
from utils import click_css, confirm_prompt
from .utils import click_css, confirm_prompt, type_in_codemirror
class ContainerPage(PageObject):
@@ -285,6 +285,7 @@ class XBlockWrapper(PageObject):
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',
'settings_tab': '.editor-modes .settings-button',
'save_settings': '.action-save',
}
@@ -312,6 +313,14 @@ class XBlockWrapper(PageObject):
"""
return self.q(css=self._bounded_selector('.xblock-student_view'))[0].text
@property
def author_content(self):
"""
Returns the text content of the xblock as displayed on the container page.
(For blocks which implement a distinct author_view).
"""
return self.q(css=self._bounded_selector('.xblock-author_view'))[0].text
@property
def name(self):
titles = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).text
@@ -336,6 +345,47 @@ class XBlockWrapper(PageObject):
grand_locators = [grandkid.locator for grandkid in grandkids]
return [descendant for descendant in descendants if descendant.locator not in grand_locators]
@property
def has_validation_message(self):
""" Is a validation warning/error/message shown? """
return self.q(css=self._bounded_selector('.xblock-message.validation')).present
def _validation_paragraph(self, css_class):
""" Helper method to return the <p> element of a validation warning """
return self.q(css=self._bounded_selector('.xblock-message.validation p.{}'.format(css_class)))
@property
def has_validation_warning(self):
""" Is a validation warning shown? """
return self._validation_paragraph('warning').present
@property
def has_validation_error(self):
""" Is a validation error shown? """
return self._validation_paragraph('error').present
@property
# pylint: disable=invalid-name
def has_validation_not_configured_warning(self):
""" Is a validation "not configured" message shown? """
return self._validation_paragraph('not-configured').present
@property
def validation_warning_text(self):
""" Get the text of the validation warning. """
return self._validation_paragraph('warning').text[0]
@property
def validation_error_text(self):
""" Get the text of the validation error. """
return self._validation_paragraph('error').text[0]
@property
# pylint: disable=invalid-name
def validation_not_configured_warning_text(self):
""" Get the text of the validation "not configured" message. """
return self._validation_paragraph('not-configured').text[0]
@property
def preview_selector(self):
return self._bounded_selector('.xblock-student_view,.xblock-author_view')
@@ -365,6 +415,34 @@ class XBlockWrapper(PageObject):
"""
self._click_button('basic_tab')
def open_settings_tab(self):
"""
If editing, click on the "Settings" tab
"""
self._click_button('settings_tab')
def set_field_val(self, field_display_name, field_value):
"""
If editing, set the value of a field.
"""
selector = '{} li.field label:contains("{}") + input'.format(self.editor_selector, field_display_name)
script = "$(arguments[0]).val(arguments[1]).change();"
self.browser.execute_script(script, selector, field_value)
def reset_field_val(self, field_display_name):
"""
If editing, reset the value of a field to its default.
"""
scope = '{} li.field label:contains("{}")'.format(self.editor_selector, field_display_name)
script = "$(arguments[0]).siblings('.setting-clear').click();"
self.browser.execute_script(script, scope)
def set_codemirror_text(self, text, index=0):
"""
Set the text of a CodeMirror editor that is part of this xblock's settings.
"""
type_in_codemirror(self, index, text, find_prefix='$("{}").find'.format(self.editor_selector))
def save_settings(self):
"""
Click on settings Save button.

View File

@@ -1,5 +1,5 @@
"""
My Courses page in Studio
Studio Home page
"""
from bok_choy.page_object import PageObject
@@ -8,7 +8,7 @@ from . import BASE_URL
class DashboardPage(PageObject):
"""
My Courses page in Studio
Studio Home page
"""
url = BASE_URL + "/course/"
@@ -40,3 +40,69 @@ class DashboardPage(PageObject):
Clicks on the course with run given by run.
"""
self.q(css='.course-run .value').filter(lambda el: el.text == run)[0].click()
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').click()
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='.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()
def list_libraries(self):
"""
List all the libraries found on the page's list of libraries.
"""
# Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements
self.q(css='#course-index-tabs .libraries-tab a').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,
'url': element.find_element_by_css_selector('a.library-link').get_attribute('href'),
}
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

View File

@@ -0,0 +1,281 @@
"""
Library edit page in Studio
"""
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 .overview import CourseOutlineModal
from .container import XBlockWrapper
from ...pages.studio.pagination import PaginatedMixin
from ...tests.helpers import disable_animations
from .utils import confirm_prompt, wait_for_notification
from . import BASE_URL
class LibraryPage(PageObject, PaginatedMixin):
"""
Library page in Studio
"""
def __init__(self, browser, locator):
super(LibraryPage, self).__init__(browser)
self.locator = locator
@property
def url(self):
"""
URL to the library edit page for the given library.
"""
return "{}/library/{}".format(BASE_URL, unicode(self.locator))
def is_browser_on_page(self):
"""
Returns True iff the browser has loaded the library edit page.
"""
return self.q(css='body.view-library').present
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
functionality is not yet available. This waits for that loading to
finish.
Always call this before using the page. It also disables animations
for improved test reliability.
"""
self.wait_for_ajax()
self.wait_for_element_invisibility(
'.ui-loading',
'Wait for the page to complete its initial loading of XBlocks via AJAX'
)
disable_animations(self)
@property
def xblocks(self):
"""
Return a list of xblocks loaded on the container page.
"""
return self._get_xblocks()
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()
wait_for_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 wait_for_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(
'.header-actions .{action}-button.action-button'.format(action=action)
)
class StudioLibraryContentXBlockEditModal(CourseOutlineModal, PageObject):
"""
Library Content XBlock Modal edit window
"""
url = None
MODAL_SELECTOR = ".wrapper-modal-window-edit-xblock"
# Labels used to identify the fields on the edit modal:
LIBRARY_LABEL = "Libraries"
COUNT_LABEL = "Count"
SCORED_LABEL = "Scored"
PROBLEM_TYPE_LABEL = "Problem Type"
def is_browser_on_page(self):
"""
Check that we are on the right page in the browser.
"""
return self.is_shown()
@property
def library_key(self):
"""
Gets value of first library key input
"""
library_key_input = self.get_metadata_input(self.LIBRARY_LABEL)
if library_key_input is not None:
return library_key_input.get_attribute('value').strip(',')
return None
@library_key.setter
def library_key(self, library_key):
"""
Sets value of first library key input, creating it if necessary
"""
library_key_input = self.get_metadata_input(self.LIBRARY_LABEL)
if library_key_input is None:
library_key_input = self._add_library_key()
if library_key is not None:
# can't use lib_text.clear() here as input get deleted by client side script
library_key_input.send_keys(Keys.HOME)
library_key_input.send_keys(Keys.SHIFT, Keys.END)
library_key_input.send_keys(library_key)
else:
library_key_input.clear()
EmptyPromise(lambda: self.library_key == library_key, "library_key is updated in modal.").fulfill()
@property
def count(self):
"""
Gets value of children count input
"""
return int(self.get_metadata_input(self.COUNT_LABEL).get_attribute('value'))
@count.setter
def count(self, count):
"""
Sets value of children count input
"""
count_text = self.get_metadata_input(self.COUNT_LABEL)
count_text.clear()
count_text.send_keys(count)
EmptyPromise(lambda: self.count == count, "count is updated in modal.").fulfill()
@property
def scored(self):
"""
Gets value of scored select
"""
value = self.get_metadata_input(self.SCORED_LABEL).get_attribute('value')
if value == 'True':
return True
elif value == 'False':
return False
raise ValueError("Unknown value {value} set for {label}".format(value=value, label=self.SCORED_LABEL))
@scored.setter
def scored(self, scored):
"""
Sets value of scored select
"""
select_element = self.get_metadata_input(self.SCORED_LABEL)
select_element.click()
scored_select = Select(select_element)
scored_select.select_by_value(str(scored))
EmptyPromise(lambda: self.scored == scored, "scored is updated in modal.").fulfill()
@property
def capa_type(self):
"""
Gets value of CAPA type select
"""
return self.get_metadata_input(self.PROBLEM_TYPE_LABEL).get_attribute('value')
@capa_type.setter
def capa_type(self, value):
"""
Sets value of CAPA type select
"""
select_element = self.get_metadata_input(self.PROBLEM_TYPE_LABEL)
select_element.click()
problem_type_select = Select(select_element)
problem_type_select.select_by_value(value)
EmptyPromise(lambda: self.capa_type == value, "problem type is updated in modal.").fulfill()
def _add_library_key(self):
"""
Adds library key input
"""
wrapper = self._get_metadata_element(self.LIBRARY_LABEL)
add_button = wrapper.find_element_by_xpath(".//a[contains(@class, 'create-action')]")
add_button.click()
return self._get_list_inputs(wrapper)[0]
def _get_list_inputs(self, list_wrapper):
"""
Finds nested input elements (useful for List and Dict fields)
"""
return list_wrapper.find_elements_by_xpath(".//input[@type='text']")
def _get_metadata_element(self, metadata_key):
"""
Gets metadata input element (a wrapper div for List and Dict fields)
"""
metadata_inputs = self.find_css(".metadata_entry .wrapper-comp-setting label.setting-label")
target_label = [elem for elem in metadata_inputs if elem.text == metadata_key][0]
label_for = target_label.get_attribute('for')
return self.find_css("#" + label_for)[0]
def get_metadata_input(self, metadata_key):
"""
Gets input/select element for given field
"""
element = self._get_metadata_element(metadata_key)
if element.tag_name == 'div':
# List or Dict field - return first input
# TODO support multiple values
inputs = self._get_list_inputs(element)
element = inputs[0] if inputs else None
return element
class StudioLibraryContainerXBlockWrapper(XBlockWrapper):
"""
Wraps :class:`.container.XBlockWrapper` for use with LibraryContent blocks
"""
url = None
@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"))
def refresh_children(self):
"""
Click "Update now..." button
"""
btn_selector = self._bounded_selector(".library-update-btn")
refresh_button = self.q(css=btn_selector)
refresh_button.click()
self.wait_for_element_absence(btn_selector, 'Wait for the XBlock to reload')

View File

@@ -0,0 +1,62 @@
"""
Mixin to include for Paginated container pages
"""
from selenium.webdriver.common.keys import Keys
class PaginatedMixin(object):
"""
Mixin class used for paginated page tests.
"""
def nav_disabled(self, position, arrows=('next', 'previous')):
"""
Verifies that pagination nav is disabled. Position can be 'top' or 'bottom'.
`top` is the header, `bottom` is the footer.
To specify a specific arrow, pass an iterable with a single element, 'next' or 'previous'.
"""
return all([
self.q(css='nav.%s * a.%s-page-link.is-disabled' % (position, arrow))
for arrow in arrows
])
def move_back(self, position):
"""
Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
"""
self.q(css='nav.%s * a.previous-page-link' % position)[0].click()
self.wait_until_ready()
def move_forward(self, position):
"""
Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
"""
self.q(css='nav.%s * a.next-page-link' % position)[0].click()
self.wait_until_ready()
def go_to_page(self, number):
"""
Enter a number into the page number input field, and then try to navigate to it.
"""
page_input = self.q(css="#page-number-input")[0]
page_input.click()
page_input.send_keys(str(number))
page_input.send_keys(Keys.RETURN)
self.wait_until_ready()
def get_page_number(self):
"""
Returns the page number as the page represents it, in string form.
"""
return self.q(css="span.current-page")[0].get_attribute('innerHTML')
def check_page_unchanged(self, first_block_name):
"""
Used to make sure that a page has not transitioned after a bogus number is given.
"""
if not self.xblocks[0].name == first_block_name:
return False
if not self.q(css='#page-number-input')[0].get_attribute('value') == '':
return False
return True

View File

@@ -0,0 +1,191 @@
"""
Page classes to test either the Course Team page or the Library Team page.
"""
from bok_choy.promise import EmptyPromise
from bok_choy.page_object import PageObject
from ...tests.helpers import disable_animations
from . import BASE_URL
def wait_for_ajax_or_reload(browser):
"""
Wait for all ajax requests to finish, OR for the page to reload.
Normal wait_for_ajax() chokes on occasion if the pages reloads,
giving "WebDriverException: Message: u'jQuery is not defined'"
"""
def _is_ajax_finished():
""" Wait for jQuery to finish all AJAX calls, if it is present. """
return browser.execute_script("return typeof(jQuery) == 'undefined' || jQuery.active == 0")
EmptyPromise(_is_ajax_finished, "Finished waiting for ajax requests.").fulfill()
class UsersPage(PageObject):
"""
Base class for either the Course Team page or the Library Team page
"""
def __init__(self, browser, locator):
super(UsersPage, self).__init__(browser)
self.locator = locator
@property
def url(self):
"""
URL to this page - override in subclass
"""
raise NotImplementedError
def is_browser_on_page(self):
"""
Returns True iff the browser has loaded the page.
"""
return self.q(css='body.view-team').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 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').click()
@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)
class LibraryUsersPage(UsersPage):
"""
Library Team page in Studio
"""
@property
def url(self):
"""
URL to the "User Access" page for the given library.
"""
return "{}/library/{}/team/".format(BASE_URL, unicode(self.locator))
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 = '.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 '{} {}'.format(self.selector, selector)
@property
def name(self):
""" Get this user's username, as displayed. """
return self.q(css=self._bounded_selector('.user-username')).text[0]
@property
def role_label(self):
""" Get this user's role, as displayed. """
return self.q(css=self._bounded_selector('.flag-role .value')).text[0]
@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? """
return self.q(css=self._bounded_selector('.add-admin-role')).text[0]
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? """
return self.q(css=self._bounded_selector('.remove-admin-role')).text[0]
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()
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]

View File

@@ -103,6 +103,33 @@ def add_advanced_component(page, menu_index, name):
click_css(page, component_css, 0)
def add_component(page, item_type, specific_type):
"""
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='.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 = '.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'
)
all_options = page.q(css='.new-component-{} ul.new-component-template li a span'.format(item_type))
chosen_option = all_options.filter(lambda el: el.text == specific_type).first
chosen_option.click()
wait_for_notification(page)
page.wait_for_ajax()
@js_defined('window.jQuery')
def type_in_codemirror(page, index, text, find_prefix="$"):
script = """

View File

@@ -0,0 +1,303 @@
# -*- coding: utf-8 -*-
"""
End-to-end tests for LibraryContent block in LMS
"""
import ddt
import textwrap
from ..helpers import UniqueCourseTest
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.library import StudioLibraryContentXBlockEditModal, StudioLibraryContainerXBlockWrapper
from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.library import LibraryContentXBlockWrapper
from ...pages.common.logout import LogoutPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from ...fixtures.library import LibraryFixture
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"
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.course_outline = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.library_fixture = LibraryFixture('test_org', self.unique_id, '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_libraries': [self.library_key],
'mode': 'random',
'max_count': 1,
'has_score': False
}
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[0])
modal = StudioLibraryContentXBlockEditModal(library_container_block.edit())
modal.count = count
if capa_type is not None:
modal.capa_type = capa_type
library_container_block.save_settings()
self._go_to_unit_page(change_login=False)
unit_page.wait_for_page()
unit_page.publish_action.click()
unit_page.wait_for_ajax()
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.course_outline.visit()
subsection = self.course_outline.section(SECTION_NAME).subsection(SUBSECTION_NAME)
return subsection.toggle_expand().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')
if paragraphs and "You were most recently in" in paragraphs.text[0]:
paragraphs[0].find_element_by_tag_name('a').click()
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.
"""
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'),
)
@ddt.data(1, 2, 3)
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), 3)
self.assertEqual(children_contents, self.library_xblocks_texts)
@ddt.ddt
class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase):
"""
Test Library Content block in LMS
"""
def _get_problem_choice_group_text(self, name, items):
""" Generates Choice Group CAPA problem XML """
items_text = "\n".join([
"<choice correct='{correct}'>{item}</choice>".format(correct=correct, item=item)
for item, correct in items
])
return textwrap.dedent("""
<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("""
<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
"""
library_fixture.add_children(
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")
),
)
@property
def _problem_headers(self):
""" Expected XBLock headers according to populate_library_fixture """
return frozenset(child.display_name.upper() 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
def test_problem_type_selector(self):
"""
Scenario: Ensure setting "Any Type" for Problem Type does not filter out Problems
Given I have a library with two "Select Option" and two "Choice Group" problems, and a course containing
LibraryContent XBlock configured to draw XBlocks from that library
When I set library content xblock Problem Type to "Any Type" and Count to 3 and publish unit
When I go to LMS courseware page for library content xblock as student
Then I can see 3 xblocks from the library of any type
When I set library content xblock Problem Type to "Choice Group" and Count to 1 and publish unit
When I go to LMS courseware page for library content xblock as student
Then I can see 1 xblock from the library of "Choice Group" type
When I set library content xblock Problem Type to "Select Option" and Count to 2 and publish unit
When I go to LMS courseware page for library content xblock as student
Then I can see 2 xblock from the library of "Select Option" type
When I set library content xblock Problem Type to "Matlab" and Count to 2 and publish unit
When I go to LMS courseware page for library content xblock as student
Then I can see 0 xblocks from the library
"""
children_headers = self._set_library_content_settings(count=3, capa_type="Any Type")
self.assertEqual(len(children_headers), 3)
self.assertLessEqual(children_headers, self._problem_headers)
# Choice group test
children_headers = self._set_library_content_settings(count=1, capa_type="Multiple Choice")
self.assertEqual(len(children_headers), 1)
self.assertLessEqual(
children_headers,
set([header.upper() for header in ["Problem Choice Group 1", "Problem Choice Group 2"]])
)
# Choice group test
children_headers = self._set_library_content_settings(count=2, capa_type="Dropdown")
self.assertEqual(len(children_headers), 2)
self.assertEqual(
children_headers,
set([header.upper() for header in ["Problem Select 1", "Problem Select 2"]])
)
# Missing problem type test
children_headers = self._set_library_content_settings(count=2, capa_type="Custom Evaluated Script")
self.assertEqual(children_headers, set())

View File

@@ -119,7 +119,7 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
def test_password_reset_success(self):
# Create a user account
email, password = self._create_unique_user()
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)
@@ -141,6 +141,9 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
)
def _create_unique_user(self):
"""
Create a new user with a unique name and email.
"""
username = "test_{uuid}".format(uuid=self.unique_id[0:6])
email = "{user}@example.com".format(user=username)
password = "password"

View File

@@ -1,5 +1,10 @@
"""
Base classes used by studio tests.
"""
from bok_choy.web_app_test import WebAppTest
from ...pages.studio.auto_auth import AutoAuthPage
from ...fixtures.course import CourseFixture
from ...fixtures.library import LibraryFixture
from ..helpers import UniqueCourseTest
from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.utils import verify_ordering
@@ -98,3 +103,48 @@ class ContainerBase(StudioCourseTest):
# Reload the page to see that the change was persisted.
container = self.go_to_nested_container_page()
verify_ordering(self, container, expected_ordering)
class StudioLibraryTest(WebAppTest):
"""
Base class for all Studio library tests.
"""
as_staff = True
def setUp(self): # pylint: disable=arguments-differ
"""
Install a library with no content using a fixture.
"""
super(StudioLibraryTest, self).setUp()
fixture = LibraryFixture(
'test_org',
self.unique_id,
'Test Library {}'.format(self.unique_id),
)
self.populate_library_fixture(fixture)
fixture.install()
self.library_fixture = fixture
self.library_info = fixture.library_info
self.library_key = fixture.library_key
self.user = fixture.user
self.log_in(self.user, self.as_staff)
def populate_library_fixture(self, library_fixture):
"""
Populate the children of the test course fixture.
"""
pass
def log_in(self, user, is_staff=False):
"""
Log in as the user that created the library.
By default the user will not have staff access unless is_staff is passed as True.
"""
auth_page = AutoAuthPage(
self.browser,
staff=is_staff,
username=user.get('username'),
email=user.get('email'),
password=user.get('password')
)
auth_page.visit()

View File

@@ -88,6 +88,15 @@ class CoursePagesTest(StudioCourseTest):
]
]
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')
@skip('Intermittently failing with Page not found error for Assets. TE-418')
def test_page_existence(self):
"""

View File

@@ -0,0 +1,57 @@
"""
Acceptance tests for Home Page (My Courses / My Libraries).
"""
from bok_choy.web_app_test import WebAppTest
from opaque_keys.edx.locator import LibraryLocator
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.studio.library import LibraryPage
from ...pages.studio.index import DashboardPage
class CreateLibraryTest(WebAppTest):
"""
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)
def test_create_library(self):
"""
From the home page:
Click "New Library"
Fill out the form
Submit the form
We should be redirected to the edit view for the library
Return to the home page
The newly created library should now appear in the list of libraries
"""
name = "New Library Name"
org = "TestOrgX"
number = "TESTLIB"
self.auth_page.visit()
self.dashboard_page.visit()
self.assertFalse(self.dashboard_page.has_library(name=name, org=org, number=number))
self.assertTrue(self.dashboard_page.has_new_library_button())
self.dashboard_page.click_new_library()
self.assertTrue(self.dashboard_page.is_new_library_form_visible())
self.dashboard_page.fill_new_library_form(name, org, number)
self.assertTrue(self.dashboard_page.is_new_library_form_valid())
self.dashboard_page.submit_new_library_form()
# The next page is the library edit view; make sure it loads:
lib_page = LibraryPage(self.browser, LibraryLocator(org, number))
lib_page.wait_for_page()
# Then go back to the home page and make sure the new library is listed there:
self.dashboard_page.visit()
self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number))

View File

@@ -0,0 +1,466 @@
"""
Acceptance tests for Content Libraries in Studio
"""
from ddt import ddt, data
from .base_studio_test import StudioLibraryTest
from ...fixtures.course import XBlockFixtureDesc
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.studio.utils import add_component
from ...pages.studio.library import LibraryPage
from ...pages.studio.users import LibraryUsersPage
@ddt
class LibraryEditPageTest(StudioLibraryTest):
"""
Test the functionality of the library edit page.
"""
def setUp(self): # pylint: disable=arguments-differ
"""
Ensure a library exists and navigate to the library edit page.
"""
super(LibraryEditPageTest, self).setUp()
self.lib_page = LibraryPage(self.browser, self.library_key)
self.lib_page.visit()
self.lib_page.wait_until_ready()
def test_page_header(self):
"""
Scenario: Ensure that the library's name is displayed in the header and title.
Given I have a library in Studio
And I navigate to Library Page in Studio
Then I can see library name in page header title
And I can see library name in browser page title
"""
self.assertIn(self.library_info['display_name'], self.lib_page.get_header_title())
self.assertIn(self.library_info['display_name'], self.browser.title)
def test_add_duplicate_delete_actions(self):
"""
Scenario: Ensure that we can add an HTML block, duplicate it, then delete the original.
Given I have a library in Studio with no XBlocks
And I navigate to Library Page in Studio
Then there are no XBlocks displayed
When I add Text XBlock
Then one XBlock is displayed
When I duplicate first XBlock
Then two XBlocks are displayed
And those XBlocks locators' are different
When I delete first XBlock
Then one XBlock is displayed
And displayed XBlock are second one
"""
self.assertEqual(len(self.lib_page.xblocks), 0)
# Create a new block:
add_component(self.lib_page, "html", "Text")
self.assertEqual(len(self.lib_page.xblocks), 1)
first_block_id = self.lib_page.xblocks[0].locator
# Duplicate the block:
self.lib_page.click_duplicate_button(first_block_id)
self.assertEqual(len(self.lib_page.xblocks), 2)
second_block_id = self.lib_page.xblocks[1].locator
self.assertNotEqual(first_block_id, second_block_id)
# Delete the first block:
self.lib_page.click_delete_button(first_block_id, confirm=True)
self.assertEqual(len(self.lib_page.xblocks), 1)
self.assertEqual(self.lib_page.xblocks[0].locator, second_block_id)
def test_add_edit_xblock(self):
"""
Scenario: Ensure that we can add an XBlock, edit it, then see the resulting changes.
Given I have a library in Studio with no XBlocks
And I navigate to Library Page in Studio
Then there are no XBlocks displayed
When I add Multiple Choice XBlock
Then one XBlock is displayed
When I edit first XBlock
And I go to basic tab
And set it's text to a fairly trivial question about Battlestar Galactica
And save XBlock
Then one XBlock is displayed
And first XBlock student content contains at least part of text I set
"""
self.assertEqual(len(self.lib_page.xblocks), 0)
# Create a new problem block:
add_component(self.lib_page, "problem", "Multiple Choice")
self.assertEqual(len(self.lib_page.xblocks), 1)
problem_block = self.lib_page.xblocks[0]
# Edit it:
problem_block.edit()
problem_block.open_basic_tab()
problem_block.set_codemirror_text(
"""
>>Who is "Starbuck"?<<
(x) Kara Thrace
( ) William Adama
( ) Laura Roslin
( ) Lee Adama
( ) Gaius Baltar
"""
)
problem_block.save_settings()
# Check that the save worked:
self.assertEqual(len(self.lib_page.xblocks), 1)
problem_block = self.lib_page.xblocks[0]
self.assertIn("Laura Roslin", problem_block.student_content)
def test_no_discussion_button(self):
"""
Ensure the UI is not loaded for adding discussions.
"""
self.assertFalse(self.browser.find_elements_by_css_selector('span.large-discussion-icon'))
def test_library_pagination(self):
"""
Scenario: Ensure that adding several XBlocks to a library results in pagination.
Given that I have a library in Studio with no XBlocks
And I create 10 Multiple Choice XBlocks
Then 10 are displayed.
When I add one more Multiple Choice XBlock
Then 1 XBlock will be displayed
When I delete that XBlock
Then 10 are displayed.
"""
self.assertEqual(len(self.lib_page.xblocks), 0)
for _ in range(0, 10):
add_component(self.lib_page, "problem", "Multiple Choice")
self.assertEqual(len(self.lib_page.xblocks), 10)
add_component(self.lib_page, "problem", "Multiple Choice")
self.assertEqual(len(self.lib_page.xblocks), 1)
self.lib_page.click_delete_button(self.lib_page.xblocks[0].locator)
self.assertEqual(len(self.lib_page.xblocks), 10)
@data('top', 'bottom')
def test_nav_present_but_disabled(self, position):
"""
Scenario: Ensure that the navigation buttons aren't active when there aren't enough XBlocks.
Given that I have a library in Studio with no XBlocks
The Navigation buttons should be disabled.
When I add a multiple choice problem
The Navigation buttons should be disabled.
"""
self.assertEqual(len(self.lib_page.xblocks), 0)
self.assertTrue(self.lib_page.nav_disabled(position))
add_component(self.lib_page, "problem", "Multiple Choice")
self.assertTrue(self.lib_page.nav_disabled(position))
def test_delete_deletes_only_desired_block(self):
"""
Scenario: Ensure that when deleting XBlock only desired XBlock is deleted
Given that I have a library in Studio with no XBlocks
And I create Blank Common Problem XBlock
And I create Checkboxes XBlock
When I delete Blank Problem XBlock
Then Checkboxes XBlock is not deleted
And Blank Common Problem XBlock is deleted
"""
self.assertEqual(len(self.lib_page.xblocks), 0)
add_component(self.lib_page, "problem", "Blank Common Problem")
add_component(self.lib_page, "problem", "Checkboxes")
self.assertEqual(len(self.lib_page.xblocks), 2)
self.assertIn("Blank Common Problem", self.lib_page.xblocks[0].name)
self.assertIn("Checkboxes", self.lib_page.xblocks[1].name)
self.lib_page.click_delete_button(self.lib_page.xblocks[0].locator)
self.assertEqual(len(self.lib_page.xblocks), 1)
problem_block = self.lib_page.xblocks[0]
self.assertIn("Checkboxes", problem_block.name)
@ddt
class LibraryNavigationTest(StudioLibraryTest):
"""
Test common Navigation actions
"""
def setUp(self): # pylint: disable=arguments-differ
"""
Ensure a library exists and navigate to the library edit page.
"""
super(LibraryNavigationTest, self).setUp()
self.lib_page = LibraryPage(self.browser, self.library_key)
self.lib_page.visit()
self.lib_page.wait_until_ready()
def populate_library_fixture(self, library_fixture):
"""
Create four pages worth of XBlocks, and offset by one so each is named
after the number they should be in line by the user's perception.
"""
# pylint: disable=attribute-defined-outside-init
self.blocks = [XBlockFixtureDesc('html', str(i)) for i in xrange(1, 41)]
library_fixture.add_children(*self.blocks)
def test_arbitrary_page_selection(self):
"""
Scenario: I can pick a specific page number of a Library at will.
Given that I have a library in Studio with 40 XBlocks
When I go to the 3rd page
The first XBlock should be the 21st XBlock
When I go to the 4th Page
The first XBlock should be the 31st XBlock
When I go to the 1st page
The first XBlock should be the 1st XBlock
When I go to the 2nd page
The first XBlock should be the 11th XBlock
"""
self.lib_page.go_to_page(3)
self.assertEqual(self.lib_page.xblocks[0].name, '21')
self.lib_page.go_to_page(4)
self.assertEqual(self.lib_page.xblocks[0].name, '31')
self.lib_page.go_to_page(1)
self.assertEqual(self.lib_page.xblocks[0].name, '1')
self.lib_page.go_to_page(2)
self.assertEqual(self.lib_page.xblocks[0].name, '11')
def test_bogus_page_selection(self):
"""
Scenario: I can't pick a nonsense page number of a Library
Given that I have a library in Studio with 40 XBlocks
When I attempt to go to the 'a'th page
The input field will be cleared and no change of XBlocks will be made
When I attempt to visit the 5th page
The input field will be cleared and no change of XBlocks will be made
When I attempt to visit the -1st page
The input field will be cleared and no change of XBlocks will be made
When I attempt to visit the 0th page
The input field will be cleared and no change of XBlocks will be made
"""
self.assertEqual(self.lib_page.xblocks[0].name, '1')
self.lib_page.go_to_page('a')
self.assertTrue(self.lib_page.check_page_unchanged('1'))
self.lib_page.go_to_page(-1)
self.assertTrue(self.lib_page.check_page_unchanged('1'))
self.lib_page.go_to_page(5)
self.assertTrue(self.lib_page.check_page_unchanged('1'))
self.lib_page.go_to_page(0)
self.assertTrue(self.lib_page.check_page_unchanged('1'))
@data('top', 'bottom')
def test_nav_buttons(self, position):
"""
Scenario: Ensure that the navigation buttons work.
Given that I have a library in Studio with 40 XBlocks
The previous button should be disabled.
The first XBlock should be the 1st XBlock
Then if I hit the next button
The first XBlock should be the 11th XBlock
Then if I hit the next button
The first XBlock should be the 21st XBlock
Then if I hit the next button
The first XBlock should be the 31st XBlock
And the next button should be disabled
Then if I hit the previous button
The first XBlock should be the 21st XBlock
Then if I hit the previous button
The first XBlock should be the 11th XBlock
Then if I hit the previous button
The first XBlock should be the 1st XBlock
And the previous button should be disabled
"""
# Check forward navigation
self.assertTrue(self.lib_page.nav_disabled(position, ['previous']))
self.assertEqual(self.lib_page.xblocks[0].name, '1')
self.lib_page.move_forward(position)
self.assertEqual(self.lib_page.xblocks[0].name, '11')
self.lib_page.move_forward(position)
self.assertEqual(self.lib_page.xblocks[0].name, '21')
self.lib_page.move_forward(position)
self.assertEqual(self.lib_page.xblocks[0].name, '31')
self.lib_page.nav_disabled(position, ['next'])
# Check backward navigation
self.lib_page.move_back(position)
self.assertEqual(self.lib_page.xblocks[0].name, '21')
self.lib_page.move_back(position)
self.assertEqual(self.lib_page.xblocks[0].name, '11')
self.lib_page.move_back(position)
self.assertEqual(self.lib_page.xblocks[0].name, '1')
self.assertTrue(self.lib_page.nav_disabled(position, ['previous']))
def test_library_pagination(self):
"""
Scenario: Ensure that adding several XBlocks to a library results in pagination.
Given that I have a library in Studio with 40 XBlocks
Then 10 are displayed
And the first XBlock will be the 1st one
And I'm on the 1st page
When I add 1 Multiple Choice XBlock
Then 1 XBlock will be displayed
And I'm on the 5th page
The first XBlock will be the newest one
When I delete that XBlock
Then 10 are displayed
And I'm on the 4th page
And the first XBlock is the 31st one
And the last XBlock is the 40th one.
"""
self.assertEqual(len(self.lib_page.xblocks), 10)
self.assertEqual(self.lib_page.get_page_number(), '1')
self.assertEqual(self.lib_page.xblocks[0].name, '1')
add_component(self.lib_page, "problem", "Multiple Choice")
self.assertEqual(len(self.lib_page.xblocks), 1)
self.assertEqual(self.lib_page.get_page_number(), '5')
self.assertEqual(self.lib_page.xblocks[0].name, "Multiple Choice")
self.lib_page.click_delete_button(self.lib_page.xblocks[0].locator)
self.assertEqual(len(self.lib_page.xblocks), 10)
self.assertEqual(self.lib_page.get_page_number(), '4')
self.assertEqual(self.lib_page.xblocks[0].name, '31')
self.assertEqual(self.lib_page.xblocks[-1].name, '40')
def test_delete_shifts_blocks(self):
"""
Scenario: Ensure that removing an XBlock shifts other blocks back.
Given that I have a library in Studio with 40 XBlocks
Then 10 are displayed
And I will be on the first page
When I delete the third XBlock
There will be 10 displayed
And the first XBlock will be the first one
And the last XBlock will be the 11th one
And I will be on the first page
"""
self.assertEqual(len(self.lib_page.xblocks), 10)
self.assertEqual(self.lib_page.get_page_number(), '1')
self.lib_page.click_delete_button(self.lib_page.xblocks[2].locator, confirm=True)
self.assertEqual(len(self.lib_page.xblocks), 10)
self.assertEqual(self.lib_page.xblocks[0].name, '1')
self.assertEqual(self.lib_page.xblocks[-1].name, '11')
self.assertEqual(self.lib_page.get_page_number(), '1')
class LibraryUsersPageTest(StudioLibraryTest):
"""
Test the functionality of the library "Instructor Access" page.
"""
def setUp(self):
super(LibraryUsersPageTest, self).setUp()
# Create a second user for use in these tests:
AutoAuthPage(self.browser, username="second", email="second@example.com", no_login=True).visit()
self.page = LibraryUsersPage(self.browser, self.library_key)
self.page.visit()
def _expect_refresh(self):
"""
Wait for the page to reload.
"""
self.page = LibraryUsersPage(self.browser, self.library_key).wait_for_page()
def test_user_management(self):
"""
Scenario: Ensure that we can edit the permissions of users.
Given I have a library in Studio where I am the only admin
assigned (which is the default for a newly-created library)
And I navigate to Library "Instructor Access" Page in Studio
Then there should be one user listed (myself), and I must
not be able to remove myself or my instructor privilege.
When I click Add Intructor
Then I see a form to complete
When I complete the form and submit it
Then I can see the new user is listed as a "User" of the library
When I click to Add Staff permissions to the new user
Then I can see the new user has staff permissions and that I am now
able to promote them to an Admin or remove their staff permissions.
When I click to Add Admin permissions to the new user
Then I can see the new user has admin permissions and that I can now
remove Admin permissions from either user.
"""
def check_is_only_admin(user):
"""
Ensure user is an admin user and cannot be removed.
(There must always be at least one admin user.)
"""
self.assertIn("admin", user.role_label.lower())
self.assertFalse(user.can_promote)
self.assertFalse(user.can_demote)
self.assertFalse(user.can_delete)
self.assertTrue(user.has_no_change_warning)
self.assertIn("Promote another member to Admin to remove your admin rights", user.no_change_warning_text)
self.assertEqual(len(self.page.users), 1)
user = self.page.users[0]
self.assertTrue(user.is_current_user)
check_is_only_admin(user)
# Add a new user:
self.assertTrue(self.page.has_add_button)
self.assertFalse(self.page.new_user_form_visible)
self.page.click_add_button()
self.assertTrue(self.page.new_user_form_visible)
self.page.set_new_user_email('second@example.com')
self.page.click_submit_new_user_form()
# Check the new user's listing:
def get_two_users():
"""
Expect two users to be listed, one being me, and another user.
Returns me, them
"""
users = self.page.users
self.assertEqual(len(users), 2)
self.assertEqual(len([u for u in users if u.is_current_user]), 1)
if users[0].is_current_user:
return users[0], users[1]
else:
return users[1], users[0]
self._expect_refresh()
user_me, them = get_two_users()
check_is_only_admin(user_me)
self.assertIn("user", them.role_label.lower())
self.assertTrue(them.can_promote)
self.assertIn("Add Staff Access", them.promote_button_text)
self.assertFalse(them.can_demote)
self.assertTrue(them.can_delete)
self.assertFalse(them.has_no_change_warning)
# Add Staff permissions to the new user:
them.click_promote()
self._expect_refresh()
user_me, them = get_two_users()
check_is_only_admin(user_me)
self.assertIn("staff", them.role_label.lower())
self.assertTrue(them.can_promote)
self.assertIn("Add Admin Access", them.promote_button_text)
self.assertTrue(them.can_demote)
self.assertIn("Remove Staff Access", them.demote_button_text)
self.assertTrue(them.can_delete)
self.assertFalse(them.has_no_change_warning)
# Add Admin permissions to the new user:
them.click_promote()
self._expect_refresh()
user_me, them = get_two_users()
self.assertIn("admin", user_me.role_label.lower())
self.assertFalse(user_me.can_promote)
self.assertTrue(user_me.can_demote)
self.assertTrue(user_me.can_delete)
self.assertFalse(user_me.has_no_change_warning)
self.assertIn("admin", them.role_label.lower())
self.assertFalse(them.can_promote)
self.assertTrue(them.can_demote)
self.assertIn("Remove Admin Access", them.demote_button_text)
self.assertTrue(them.can_delete)
self.assertFalse(them.has_no_change_warning)
# Delete the new user:
them.click_delete()
self._expect_refresh()
self.assertEqual(len(self.page.users), 1)
user = self.page.users[0]
self.assertTrue(user.is_current_user)

View File

@@ -0,0 +1,304 @@
"""
Acceptance tests for Library Content in LMS
"""
import textwrap
import ddt
from .base_studio_test import StudioLibraryTest
from ...fixtures.course import CourseFixture
from ..helpers import UniqueCourseTest
from ...pages.studio.library import StudioLibraryContentXBlockEditModal, StudioLibraryContainerXBlockWrapper
from ...pages.studio.overview import CourseOutlinePage
from ...fixtures.course import XBlockFixtureDesc
SECTION_NAME = 'Test Section'
SUBSECTION_NAME = 'Test Subsection'
UNIT_NAME = 'Test Unit'
@ddt.ddt
class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest):
"""
Test Library Content block in LMS
"""
def setUp(self):
"""
Install library with some content and a course using fixtures
"""
super(StudioLibraryContainerTest, self).setUp()
# Also create a course:
self.course_fixture = CourseFixture(
self.course_info['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name']
)
self.populate_course_fixture(self.course_fixture)
self.course_fixture.install()
self.outline = CourseOutlinePage(
self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']
)
self.outline.visit()
subsection = self.outline.section(SECTION_NAME).subsection(SUBSECTION_NAME)
self.unit_page = subsection.toggle_expand().unit(UNIT_NAME).go_to()
def populate_library_fixture(self, library_fixture):
"""
Populate the children of the test course fixture.
"""
library_fixture.add_children(
XBlockFixtureDesc("html", "Html1"),
XBlockFixtureDesc("html", "Html2"),
XBlockFixtureDesc("html", "Html3"),
)
def populate_course_fixture(self, course_fixture):
""" Install a course with sections/problems, tabs, updates, and handouts """
library_content_metadata = {
'source_libraries': [self.library_key],
'mode': 'random',
'max_count': 1,
'has_score': False
}
course_fixture.add_children(
XBlockFixtureDesc('chapter', SECTION_NAME).add_children(
XBlockFixtureDesc('sequential', SUBSECTION_NAME).add_children(
XBlockFixtureDesc('vertical', UNIT_NAME).add_children(
XBlockFixtureDesc('library_content', "Library Content", metadata=library_content_metadata)
)
)
)
)
def _get_library_xblock_wrapper(self, xblock):
"""
Wraps xblock into :class:`...pages.studio.library.StudioLibraryContainerXBlockWrapper`
"""
return StudioLibraryContainerXBlockWrapper.from_xblock_wrapper(xblock)
@ddt.data(
('library-v1:111+111', 1, True),
('library-v1:edX+L104', 2, False),
('library-v1:OtherX+IDDQD', 3, True),
)
@ddt.unpack
def test_can_edit_metadata(self, library_key, max_count, scored):
"""
Scenario: Given I have a library, a course and library content xblock in a course
When I go to studio unit page for library content block
And I edit library content metadata and save it
Then I can ensure that data is persisted
"""
library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
edit_modal.library_key = library_key
edit_modal.count = max_count
edit_modal.scored = scored
library_container.save_settings() # saving settings
# open edit window again to verify changes are persistent
edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
self.assertEqual(edit_modal.library_key, library_key)
self.assertEqual(edit_modal.count, max_count)
self.assertEqual(edit_modal.scored, scored)
def test_no_library_shows_library_not_configured(self):
"""
Scenario: Given I have a library, a course and library content xblock in a course
When I go to studio unit page for library content block
And I edit set library key to none
Then I can see that library content block is misconfigured
"""
expected_text = 'A library has not yet been selected.'
expected_action = 'Select a Library'
library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
# precondition check - the library block should be configured before we remove the library setting
self.assertFalse(library_container.has_validation_not_configured_warning)
edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
edit_modal.library_key = None
library_container.save_settings()
self.assertTrue(library_container.has_validation_not_configured_warning)
self.assertIn(expected_text, library_container.validation_not_configured_warning_text)
self.assertIn(expected_action, library_container.validation_not_configured_warning_text)
def test_set_missing_library_shows_correct_label(self):
"""
Scenario: Given I have a library, a course and library content xblock in a course
When I go to studio unit page for library content block
And I edit set library key to non-existent library
Then I can see that library content block is misconfigured
"""
nonexistent_lib_key = 'library-v1:111+111'
expected_text = "Library is invalid, corrupt, or has been deleted."
library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
# precondition check - assert library is configured before we remove it
self.assertFalse(library_container.has_validation_error)
edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
edit_modal.library_key = nonexistent_lib_key
library_container.save_settings()
self.assertTrue(library_container.has_validation_error)
self.assertIn(expected_text, library_container.validation_error_text)
def test_out_of_date_message(self):
"""
Scenario: Given I have a library, a course and library content xblock in a course
When I go to studio unit page for library content block
Then I update the library being used
Then I refresh the page
Then I can see that library content block needs to be updated
When I click on the update link
Then I can see that the content no longer needs to be updated
"""
expected_text = "This component is out of date. The library has new content."
library_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
self.assertFalse(library_block.has_validation_warning)
# Removed this assert until a summary message is added back to the author view (SOL-192)
#self.assertIn("3 matching components", library_block.author_content)
self.library_fixture.create_xblock(self.library_fixture.library_location, XBlockFixtureDesc("html", "Html4"))
self.unit_page.visit() # Reload the page
self.assertTrue(library_block.has_validation_warning)
self.assertIn(expected_text, library_block.validation_warning_text)
library_block.refresh_children()
self.unit_page.wait_for_page() # Wait for the page to reload
library_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
self.assertFalse(library_block.has_validation_message)
# Removed this assert until a summary message is added back to the author view (SOL-192)
#self.assertIn("4 matching components", library_block.author_content)
def test_no_content_message(self):
"""
Scenario: Given I have a library, a course and library content xblock in a course
When I go to studio unit page for library content block
And I set Problem Type selector so that no libraries have matching content
Then I can see that "No matching content" warning is shown
When I set Problem Type selector so that there is matching content
Then I can see that warning messages are not shown
"""
# Add a single "Dropdown" type problem to the library (which otherwise has only HTML blocks):
self.library_fixture.create_xblock(self.library_fixture.library_location, XBlockFixtureDesc(
"problem", "Dropdown",
data=textwrap.dedent("""
<problem>
<p>Dropdown</p>
<optionresponse><optioninput label="Dropdown" options="('1', '2')" correct="'2'"></optioninput></optionresponse>
</problem>
""")
))
expected_text = 'There are no matching problem types in the specified libraries. Select another problem type'
library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
# precondition check - assert library has children matching filter criteria
self.assertFalse(library_container.has_validation_error)
self.assertFalse(library_container.has_validation_warning)
edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
self.assertEqual(edit_modal.capa_type, "Any Type") # precondition check
edit_modal.capa_type = "Custom Evaluated Script"
library_container.save_settings()
self.assertTrue(library_container.has_validation_warning)
self.assertIn(expected_text, library_container.validation_warning_text)
edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
self.assertEqual(edit_modal.capa_type, "Custom Evaluated Script") # precondition check
edit_modal.capa_type = "Dropdown"
library_container.save_settings()
# Library should contain single Dropdown problem, so now there should be no errors again
self.assertFalse(library_container.has_validation_error)
self.assertFalse(library_container.has_validation_warning)
def test_not_enough_children_blocks(self):
"""
Scenario: Given I have a library, a course and library content xblock in a course
When I go to studio unit page for library content block
And I set Problem Type selector so "Any"
Then I can see that "No matching content" warning is shown
"""
expected_tpl = "The specified libraries are configured to fetch {count} problems, " \
"but there are only {actual} matching problems."
library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
# precondition check - assert block is configured fine
self.assertFalse(library_container.has_validation_error)
self.assertFalse(library_container.has_validation_warning)
edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
edit_modal.count = 50
library_container.save_settings()
self.assertTrue(library_container.has_validation_warning)
self.assertIn(
expected_tpl.format(count=50, actual=len(self.library_fixture.children)),
library_container.validation_warning_text
)
def test_settings_overrides(self):
"""
Scenario: Given I have a library, a course and library content xblock in a course
When I go to studio unit page for library content block
And when I click the "View" link
Then I can see a preview of the blocks drawn from the library.
When I edit one of the blocks to change a setting such as "display_name",
Then I can see the new setting is overriding the library version.
When I subsequently click to refresh the content with the latest from the library,
Then I can see that the overrided version of the setting is preserved.
When I click to edit the block and reset the setting,
then I can see that the setting's field defaults back to the library version.
"""
block_wrapper_unit_page = self._get_library_xblock_wrapper(self.unit_page.xblocks[0].children[0])
container_page = block_wrapper_unit_page.go_to_container()
library_block = self._get_library_xblock_wrapper(container_page.xblocks[0])
self.assertFalse(library_block.has_validation_message)
self.assertEqual(len(library_block.children), 3)
block = library_block.children[0]
self.assertIn(block.name, ("Html1", "Html2", "Html3"))
name_default = block.name
block.edit()
new_display_name = "A new name for this HTML block"
block.set_field_val("Display Name", new_display_name)
block.save_settings()
self.assertEqual(block.name, new_display_name)
# Create a new block, causing a new library version:
self.library_fixture.create_xblock(self.library_fixture.library_location, XBlockFixtureDesc("html", "Html4"))
container_page.visit() # Reload
self.assertTrue(library_block.has_validation_warning)
library_block.refresh_children()
container_page.wait_for_page() # Wait for the page to reload
self.assertEqual(len(library_block.children), 4)
self.assertEqual(block.name, new_display_name)
# Reset:
block.edit()
block.reset_field_val("Display Name")
block.save_settings()
self.assertEqual(block.name, name_default)