Merge pull request #6459 from edx/content-libraries
Content libraries MVP
This commit is contained in:
196
common/test/acceptance/fixtures/base.py
Normal file
196
common/test/acceptance/fixtures/base.py
Normal 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'})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
93
common/test/acceptance/fixtures/library.py
Normal file
93
common/test/acceptance/fixtures/library.py
Normal 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)
|
||||
48
common/test/acceptance/pages/lms/library.py
Normal file
48
common/test/acceptance/pages/lms/library.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
281
common/test/acceptance/pages/studio/library.py
Normal file
281
common/test/acceptance/pages/studio/library.py
Normal 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')
|
||||
62
common/test/acceptance/pages/studio/pagination.py
Normal file
62
common/test/acceptance/pages/studio/pagination.py
Normal 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
|
||||
191
common/test/acceptance/pages/studio/users.py
Normal file
191
common/test/acceptance/pages/studio/users.py
Normal 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]
|
||||
@@ -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 = """
|
||||
|
||||
303
common/test/acceptance/tests/lms/test_library.py
Normal file
303
common/test/acceptance/tests/lms/test_library.py
Normal 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())
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
57
common/test/acceptance/tests/studio/test_studio_home.py
Normal file
57
common/test/acceptance/tests/studio/test_studio_home.py
Normal 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))
|
||||
466
common/test/acceptance/tests/studio/test_studio_library.py
Normal file
466
common/test/acceptance/tests/studio/test_studio_library.py
Normal 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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user