Refactored bok-choy directory structure
Added fixtures for course and xblock creation Added bok-choy Studio tests Added bok-choy tests for ora self- and ai- assessment Refactored auto-auth; added staff and course-enrollment options Removed extra javascript properties from page objects
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
# Settings for bok choy tests
|
||||
"""
|
||||
Settings for bok choy tests
|
||||
"""
|
||||
|
||||
import os
|
||||
from path import path
|
||||
@@ -11,9 +13,9 @@ from path import path
|
||||
# This is a convenience for ensuring (a) that we can consistently find the files
|
||||
# and (b) that the files are the same in Jenkins as in local dev.
|
||||
os.environ['SERVICE_VARIANT'] = 'bok_choy'
|
||||
os.environ['CONFIG_ROOT'] = path(__file__).abspath().dirname()
|
||||
os.environ['CONFIG_ROOT'] = path(__file__).abspath().dirname() #pylint: disable=E1120
|
||||
|
||||
from aws import * # pylint: disable=W0401, W0614
|
||||
from .aws import * # pylint: disable=W0401, W0614
|
||||
|
||||
|
||||
######################### Testing overrides ####################################
|
||||
@@ -22,13 +24,13 @@ from aws import * # pylint: disable=W0401, W0614
|
||||
INSTALLED_APPS += ('django_extensions',)
|
||||
|
||||
# Redirect to the test_root folder within the repo
|
||||
TEST_ROOT = CONFIG_ROOT.dirname().dirname() / "test_root"
|
||||
TEST_ROOT = CONFIG_ROOT.dirname().dirname() / "test_root" #pylint: disable=E1120
|
||||
GITHUB_REPO_ROOT = (TEST_ROOT / "data").abspath()
|
||||
LOG_DIR = (TEST_ROOT / "log").abspath()
|
||||
|
||||
# Configure Mongo modulestore to use the test folder within the repo
|
||||
for store in ["default", "direct"]:
|
||||
MODULESTORE[store]['OPTIONS']['fs_root'] = (TEST_ROOT / "data").abspath()
|
||||
MODULESTORE[store]['OPTIONS']['fs_root'] = (TEST_ROOT / "data").abspath() #pylint: disable=E1120
|
||||
|
||||
# Enable django-pipeline and staticfiles
|
||||
STATIC_ROOT = (TEST_ROOT / "staticfiles").abspath()
|
||||
@@ -37,10 +39,14 @@ PIPELINE = True
|
||||
# Silence noisy logs
|
||||
import logging
|
||||
LOG_OVERRIDES = [
|
||||
('track.middleware', logging.CRITICAL)
|
||||
('track.middleware', logging.CRITICAL),
|
||||
('edx.discussion', logging.CRITICAL),
|
||||
]
|
||||
for log_name, log_level in LOG_OVERRIDES:
|
||||
logging.getLogger(log_name).setLevel(log_level)
|
||||
|
||||
# Use the auto_auth workflow for creating users and logging them in
|
||||
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
|
||||
|
||||
# Unfortunately, we need to use debug mode to serve staticfiles
|
||||
DEBUG = True
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.contrib.auth.models import User
|
||||
from student.models import CourseEnrollment, UserProfile
|
||||
from util.testing import UrlResetMixin
|
||||
from mock import patch
|
||||
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||
@@ -19,82 +20,101 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
|
||||
# of the UrlResetMixin)
|
||||
super(AutoAuthEnabledTestCase, self).setUp()
|
||||
self.url = '/auto_auth'
|
||||
self.cms_csrf_url = "signup"
|
||||
self.lms_csrf_url = "signin_user"
|
||||
self.client = Client()
|
||||
|
||||
def test_create_user(self):
|
||||
"""
|
||||
Test that user gets created when visiting the page.
|
||||
"""
|
||||
self._auto_auth()
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
self.assertTrue(User.objects.all()[0].is_active)
|
||||
|
||||
self.client.get(self.url)
|
||||
def test_create_same_user(self):
|
||||
self._auto_auth(username='test')
|
||||
self._auto_auth(username='test')
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
|
||||
qset = User.objects.all()
|
||||
|
||||
# assert user was created and is active
|
||||
self.assertEqual(qset.count(), 1)
|
||||
user = qset[0]
|
||||
assert user.is_active
|
||||
def test_create_multiple_users(self):
|
||||
"""
|
||||
Test to make sure multiple users are created.
|
||||
"""
|
||||
self._auto_auth()
|
||||
self._auto_auth()
|
||||
self.assertEqual(User.objects.all().count(), 2)
|
||||
|
||||
def test_create_defined_user(self):
|
||||
"""
|
||||
Test that the user gets created with the correct attributes
|
||||
when they are passed as parameters on the auto-auth page.
|
||||
"""
|
||||
|
||||
self.client.get(
|
||||
self.url,
|
||||
{'username': 'robot', 'password': 'test', 'email': 'robot@edx.org'}
|
||||
self._auto_auth(
|
||||
username='robot', password='test',
|
||||
email='robot@edx.org', full_name="Robot Name"
|
||||
)
|
||||
|
||||
qset = User.objects.all()
|
||||
|
||||
# assert user was created with the correct username and password
|
||||
self.assertEqual(qset.count(), 1)
|
||||
user = qset[0]
|
||||
# Check that the user has the correct info
|
||||
user = User.objects.get(username='robot')
|
||||
self.assertEqual(user.username, 'robot')
|
||||
self.assertTrue(user.check_password('test'))
|
||||
self.assertEqual(user.email, 'robot@edx.org')
|
||||
|
||||
@patch('student.views.random.randint')
|
||||
def test_create_multiple_users(self, randint):
|
||||
# Check that the user has a profile
|
||||
user_profile = UserProfile.objects.get(user=user)
|
||||
self.assertEqual(user_profile.name, "Robot Name")
|
||||
|
||||
# By default, the user should not be global staff
|
||||
self.assertFalse(user.is_staff)
|
||||
|
||||
def test_create_staff_user(self):
|
||||
|
||||
# Create a staff user
|
||||
self._auto_auth(username='test', staff='true')
|
||||
user = User.objects.get(username='test')
|
||||
self.assertTrue(user.is_staff)
|
||||
|
||||
# Revoke staff privileges
|
||||
self._auto_auth(username='test', staff='false')
|
||||
user = User.objects.get(username='test')
|
||||
self.assertFalse(user.is_staff)
|
||||
|
||||
def test_course_enrollment(self):
|
||||
|
||||
# Create a user and enroll in a course
|
||||
course_id = "edX/Test101/2014_Spring"
|
||||
self._auto_auth(username='test', course_id=course_id)
|
||||
|
||||
# Check that a course enrollment was created for the user
|
||||
self.assertEqual(CourseEnrollment.objects.count(), 1)
|
||||
enrollment = CourseEnrollment.objects.get(course_id=course_id)
|
||||
self.assertEqual(enrollment.user.username, "test")
|
||||
|
||||
def test_double_enrollment(self):
|
||||
|
||||
# Create a user and enroll in a course
|
||||
course_id = "edX/Test101/2014_Spring"
|
||||
self._auto_auth(username='test', course_id=course_id)
|
||||
|
||||
# Make the same call again, re-enrolling the student in the same course
|
||||
self._auto_auth(username='test', course_id=course_id)
|
||||
|
||||
# Check that only one course enrollment was created for the user
|
||||
self.assertEqual(CourseEnrollment.objects.count(), 1)
|
||||
enrollment = CourseEnrollment.objects.get(course_id=course_id)
|
||||
self.assertEqual(enrollment.user.username, "test")
|
||||
|
||||
def _auto_auth(self, **params):
|
||||
"""
|
||||
Test to make sure multiple users are created.
|
||||
Make a request to the auto-auth end-point and check
|
||||
that the response is successful.
|
||||
"""
|
||||
randint.return_value = 1
|
||||
self.client.get(self.url)
|
||||
response = self.client.get(self.url, params)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
randint.return_value = 2
|
||||
self.client.get(self.url)
|
||||
|
||||
qset = User.objects.all()
|
||||
|
||||
# make sure that USER_1 and USER_2 were created correctly
|
||||
self.assertEqual(qset.count(), 2)
|
||||
user1 = qset[0]
|
||||
self.assertEqual(user1.username, 'USER_1')
|
||||
self.assertTrue(user1.check_password('PASS_1'))
|
||||
self.assertEqual(user1.email, 'USER_1_dummy_test@mitx.mit.edu')
|
||||
self.assertEqual(qset[1].username, 'USER_2')
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"MAX_AUTO_AUTH_USERS": 1})
|
||||
def test_login_already_created_user(self):
|
||||
"""
|
||||
Test that when we have reached the limit for automatic users
|
||||
a subsequent request results in an already existant one being
|
||||
logged in.
|
||||
"""
|
||||
# auto-generate 1 user (the max)
|
||||
url = '/auto_auth'
|
||||
self.client.get(url)
|
||||
|
||||
# go to the site again
|
||||
self.client.get(url)
|
||||
qset = User.objects.all()
|
||||
|
||||
# make sure it is the same user
|
||||
self.assertEqual(qset.count(), 1)
|
||||
# Check that session and CSRF are set in the response
|
||||
for cookie in ['csrftoken', 'sessionid']:
|
||||
self.assertIn(cookie, response.cookies) #pylint: disable=E1103
|
||||
self.assertTrue(response.cookies[cookie].value) #pylint: disable=E1103
|
||||
|
||||
|
||||
class AutoAuthDisabledTestCase(UrlResetMixin, TestCase):
|
||||
@@ -118,19 +138,3 @@ class AutoAuthDisabledTestCase(UrlResetMixin, TestCase):
|
||||
"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_csrf_enabled(self):
|
||||
"""
|
||||
test that when not load testing, csrf protection is on
|
||||
"""
|
||||
cms_csrf_url = "signup"
|
||||
lms_csrf_url = "signin_user"
|
||||
self.client = Client(enforce_csrf_checks=True)
|
||||
try:
|
||||
csrf_protected_url = reverse(cms_csrf_url)
|
||||
response = self.client.post(csrf_protected_url)
|
||||
except NoReverseMatch:
|
||||
csrf_protected_url = reverse(lms_csrf_url)
|
||||
response = self.client.post(csrf_protected_url)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@@ -981,54 +981,85 @@ def create_account(request, post_override=None):
|
||||
|
||||
def auto_auth(request):
|
||||
"""
|
||||
Automatically logs the user in with a generated random credentials
|
||||
This view is only accessible when
|
||||
Create or configure a user account, then log in as that user.
|
||||
|
||||
Enabled only when
|
||||
settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true.
|
||||
|
||||
Accepts the following querystring parameters:
|
||||
* `username`, `email`, and `password` for the user account
|
||||
* `full_name` for the user profile (the user's full name; defaults to the username)
|
||||
* `staff`: Set to "true" to make the user global staff.
|
||||
* `course_id`: Enroll the student in the course with `course_id`
|
||||
|
||||
If username, email, or password are not provided, use
|
||||
randomly generated credentials.
|
||||
"""
|
||||
|
||||
def get_dummy_post_data(username, password, email, name):
|
||||
"""
|
||||
Return a dictionary suitable for passing to post_vars of _do_create_account or post_override
|
||||
of create_account, with specified values.
|
||||
"""
|
||||
return {'username': username,
|
||||
'email': email,
|
||||
'password': password,
|
||||
'name': name,
|
||||
'honor_code': u'true',
|
||||
'terms_of_service': u'true', }
|
||||
|
||||
# generate random user credentials from a small name space (determined by settings)
|
||||
name_base = 'USER_'
|
||||
pass_base = 'PASS_'
|
||||
|
||||
max_users = settings.FEATURES.get('MAX_AUTO_AUTH_USERS', 200)
|
||||
number = random.randint(1, max_users)
|
||||
|
||||
# Get the params from the request to override default user attributes if specified
|
||||
qdict = request.GET
|
||||
# Generate a unique name to use if none provided
|
||||
unique_name = uuid.uuid4().hex[0:30]
|
||||
|
||||
# Use the params from the request, otherwise use these defaults
|
||||
username = qdict.get('username', name_base + str(number))
|
||||
password = qdict.get('password', pass_base + str(number))
|
||||
email = qdict.get('email', '%s_dummy_test@mitx.mit.edu' % username)
|
||||
name = qdict.get('name', '%s Test' % username)
|
||||
username = request.GET.get('username', unique_name)
|
||||
password = request.GET.get('password', unique_name)
|
||||
email = request.GET.get('email', unique_name + "@example.com")
|
||||
full_name = request.GET.get('full_name', username)
|
||||
is_staff = request.GET.get('staff', None)
|
||||
course_id = request.GET.get('course_id', None)
|
||||
|
||||
# if they already are a user, log in
|
||||
try:
|
||||
# Get or create the user object
|
||||
post_data = {
|
||||
'username': username,
|
||||
'email': email,
|
||||
'password': password,
|
||||
'name': full_name,
|
||||
'honor_code': u'true',
|
||||
'terms_of_service': u'true',
|
||||
}
|
||||
|
||||
# Attempt to create the account.
|
||||
# If successful, this will return a tuple containing
|
||||
# the new user object; otherwise it will return an error
|
||||
# message.
|
||||
result = _do_create_account(post_data)
|
||||
|
||||
if isinstance(result, tuple):
|
||||
user = result[0]
|
||||
|
||||
# If we did not create a new account, the user might already
|
||||
# exist. Attempt to retrieve it.
|
||||
else:
|
||||
user = User.objects.get(username=username)
|
||||
user = authenticate(username=username, password=password, request=request)
|
||||
login(request, user)
|
||||
user.email = email
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
|
||||
# else create and activate account info
|
||||
except ObjectDoesNotExist:
|
||||
post_override = get_dummy_post_data(username, password, email, name)
|
||||
create_account(request, post_override=post_override)
|
||||
request.user.is_active = True
|
||||
request.user.save()
|
||||
# Set the user's global staff bit
|
||||
if is_staff is not None:
|
||||
user.is_staff = (is_staff == "true")
|
||||
user.save()
|
||||
|
||||
# return empty success
|
||||
return HttpResponse('')
|
||||
# Activate the user
|
||||
reg = Registration.objects.get(user=user)
|
||||
reg.activate()
|
||||
reg.save()
|
||||
|
||||
# Enroll the user in a course
|
||||
if course_id is not None:
|
||||
CourseEnrollment.enroll(user, course_id)
|
||||
|
||||
# Log in as the user
|
||||
user = authenticate(username=username, password=password)
|
||||
login(request, user)
|
||||
|
||||
# Provide the user with a valid CSRF token
|
||||
# then return a 200 response
|
||||
success_msg = u"Logged in user {0} ({1}) with password {2}".format(
|
||||
username, email, password
|
||||
)
|
||||
response = HttpResponse(success_msg)
|
||||
response.set_cookie('csrftoken', csrf(request)['csrf_token'])
|
||||
return response
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# pylint: disable=C0111
|
||||
# pylint: disable=W0621
|
||||
|
||||
import urllib
|
||||
from lettuce import world
|
||||
from django.contrib.auth.models import User, Group
|
||||
from student.models import CourseEnrollment
|
||||
@@ -27,12 +28,13 @@ def create_user(uname, password):
|
||||
|
||||
|
||||
@world.absorb
|
||||
def log_in(username='robot', password='test', email='robot@edx.org', name='Robot'):
|
||||
def log_in(username='robot', password='test', email='robot@edx.org', name="Robot"):
|
||||
"""
|
||||
Use the auto_auth feature to programmatically log the user in
|
||||
"""
|
||||
url = '/auto_auth?username=%s&password=%s&name=%s&email=%s' % (username,
|
||||
password, name, email)
|
||||
url = '/auto_auth'
|
||||
params = { 'username': username, 'password': password, 'email': email, 'full_name': name }
|
||||
url += "?" + urllib.urlencode(params)
|
||||
world.visit(url)
|
||||
|
||||
# Save the user info in the world scenario_dict for use in the tests
|
||||
|
||||
0
common/test/acceptance/edxapp_pages/__init__.py
Normal file
0
common/test/acceptance/edxapp_pages/__init__.py
Normal file
@@ -1,34 +1,27 @@
|
||||
"""
|
||||
Course about page (with registration button)
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from ..lms import BASE_URL
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class CourseAboutPage(PageObject):
|
||||
"""
|
||||
Course about page (with registration button)
|
||||
"""
|
||||
name = "lms.course_about"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.course_about"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
|
||||
def url(self, course_id=None):
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL for the about page of a course.
|
||||
Course ID is currently of the form "edx/999/2013_Spring"
|
||||
but this format could change.
|
||||
"""
|
||||
if course_id is None:
|
||||
raise NotImplemented("Must provide a course ID to access about page")
|
||||
raise NotImplementedError("Must provide a course ID to access about page")
|
||||
|
||||
return BASE_URL + "/courses/" + course_id + "about"
|
||||
return BASE_URL + "/courses/" + course_id + "/about"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('section.course-info')
|
||||
@@ -1,5 +1,9 @@
|
||||
"""
|
||||
Course info page.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from ..lms import BASE_URL
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class CourseInfoPage(PageObject):
|
||||
@@ -7,19 +11,9 @@ class CourseInfoPage(PageObject):
|
||||
Course info.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.course_info"
|
||||
name = "lms.course_info"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
|
||||
def url(self, course_id=None):
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
Go directly to the course info page for `course_id`.
|
||||
(e.g. "edX/Open_DemoX/edx_demo_course")
|
||||
@@ -1,6 +1,10 @@
|
||||
"""
|
||||
Course navigation page object
|
||||
"""
|
||||
|
||||
import re
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise, fulfill_after
|
||||
from ..lms import BASE_URL
|
||||
|
||||
|
||||
class CourseNavPage(PageObject):
|
||||
@@ -8,24 +12,14 @@ class CourseNavPage(PageObject):
|
||||
Navigate sections and sequences in the courseware.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.course_nav"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
name = "lms.course_nav"
|
||||
|
||||
def url(self, **kwargs):
|
||||
"""
|
||||
Since course navigation appears on multiple pages,
|
||||
it doesn't have a particular URL.
|
||||
"""
|
||||
raise NotImplemented
|
||||
raise NotImplementedError
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('section.course-index')
|
||||
@@ -73,7 +67,7 @@ class CourseNavPage(PageObject):
|
||||
['Chemical Bonds Video', 'Practice Problems', 'Homework']
|
||||
"""
|
||||
seq_css = 'ol#sequence-list>li>a>p'
|
||||
return self.css_map(seq_css, lambda el: el.html.strip().split('\n')[0])
|
||||
return self.css_map(seq_css, self._clean_seq_titles)
|
||||
|
||||
def go_to_section(self, section_title, subsection_title):
|
||||
"""
|
||||
@@ -201,3 +195,12 @@ class CourseNavPage(PageObject):
|
||||
current_section_list[0].strip() == section_title and
|
||||
current_subsection_list[0].strip().split('\n')[0] == subsection_title
|
||||
)
|
||||
|
||||
# Regular expression to remove HTML span tags from a string
|
||||
REMOVE_SPAN_TAG_RE = re.compile(r'<span.+/span>')
|
||||
|
||||
def _clean_seq_titles(self, element):
|
||||
"""
|
||||
Clean HTML of sequence titles, stripping out span tags and returning the first line.
|
||||
"""
|
||||
return self.REMOVE_SPAN_TAG_RE.sub('', element.html).strip().split('\n')[0]
|
||||
@@ -1,5 +1,9 @@
|
||||
"""
|
||||
Student dashboard page.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from ..lms import BASE_URL
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class DashboardPage(PageObject):
|
||||
@@ -8,17 +12,7 @@ class DashboardPage(PageObject):
|
||||
courses she/he has registered for.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.dashboard"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
name = "lms.dashboard"
|
||||
|
||||
def url(self, **kwargs):
|
||||
return BASE_URL + "/dashboard"
|
||||
@@ -45,7 +39,9 @@ class DashboardPage(PageObject):
|
||||
self.warning(msg)
|
||||
|
||||
def _link_css(self, course_id):
|
||||
|
||||
"""
|
||||
Return a CSS selector for the link to the course with `course_id`.
|
||||
"""
|
||||
# Get the link hrefs for all courses
|
||||
all_links = self.css_map('a.enter-course', lambda el: el['href'])
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""
|
||||
Find courses page (main page of the LMS).
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import BrokenPromise
|
||||
from ..lms import BASE_URL
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class FindCoursesPage(PageObject):
|
||||
@@ -8,17 +12,7 @@ class FindCoursesPage(PageObject):
|
||||
Find courses page (main page of the LMS).
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.find_courses"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
name = "lms.find_courses"
|
||||
|
||||
def url(self):
|
||||
return BASE_URL
|
||||
@@ -60,7 +54,7 @@ class FindCoursesPage(PageObject):
|
||||
except BrokenPromise:
|
||||
# We need to escape forward slashes in the course_id
|
||||
# to create a valid CSS selector
|
||||
course_id = course_id.replace('/', '\/')
|
||||
course_id = course_id.replace('/', r'\/')
|
||||
self.css_click('article.course#{0}'.format(course_id))
|
||||
|
||||
# Ensure that we end up on the next page
|
||||
@@ -1,6 +1,10 @@
|
||||
"""
|
||||
Login page for the LMS.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise, fulfill_after
|
||||
from ..lms import BASE_URL
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class LoginPage(PageObject):
|
||||
@@ -8,17 +12,7 @@ class LoginPage(PageObject):
|
||||
Login page for the LMS.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.login"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
name = "lms.login"
|
||||
|
||||
def url(self):
|
||||
return BASE_URL + "/login"
|
||||
@@ -1,3 +1,7 @@
|
||||
"""
|
||||
Open-ended response in the courseware.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise, fulfill_after, fulfill_before
|
||||
|
||||
@@ -7,23 +11,13 @@ class OpenResponsePage(PageObject):
|
||||
Open-ended response in the courseware.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.open_response"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
name = "lms.open_response"
|
||||
|
||||
def url(self):
|
||||
"""
|
||||
Open-response isn't associated with a particular URL.
|
||||
"""
|
||||
raise NotImplemented
|
||||
raise NotImplementedError
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('section.xmodule_CombinedOpenEndedModule')
|
||||
@@ -49,7 +43,7 @@ class OpenResponsePage(PageObject):
|
||||
elif 'peer' in label_compare:
|
||||
return 'peer'
|
||||
else:
|
||||
raise ValueError("Unexpected assessment type: '{0}'".format(label))
|
||||
raise ValueError("Unexpected assessment type: '{0}'".format(label_compare))
|
||||
|
||||
@property
|
||||
def prompt(self):
|
||||
@@ -103,13 +97,16 @@ class OpenResponsePage(PageObject):
|
||||
# We need to filter out the similar-looking CSS classes
|
||||
# for the rubric items that are NOT marked correct/incorrect
|
||||
feedback_css = 'div.rubric-label>label'
|
||||
labels = filter(
|
||||
lambda el_class: el_class != 'rubric-elements-info',
|
||||
labels = [
|
||||
el_class for el_class in
|
||||
self.css_map(feedback_css, lambda el: el['class'])
|
||||
)
|
||||
if el_class != 'rubric-elements-info'
|
||||
]
|
||||
|
||||
# Map CSS classes on the labels to correct/incorrect
|
||||
def map_feedback(css_class):
|
||||
"""
|
||||
Map CSS classes on the labels to correct/incorrect
|
||||
"""
|
||||
if 'choicegroup_incorrect' in css_class:
|
||||
return 'incorrect'
|
||||
elif 'choicegroup_correct' in css_class:
|
||||
@@ -195,14 +192,14 @@ class OpenResponsePage(PageObject):
|
||||
# Check that we have the enough radio buttons
|
||||
category_css = "div.rubric>ul.rubric-list:nth-of-type({0})".format(score_index + 1)
|
||||
if scores[score_index] > self.css_count(category_css + ' input.score-selection'):
|
||||
msg = "Tried to select score {0} but there are only {1} options".format(score_num, len(inputs))
|
||||
msg = "Tried to select score {0} but there are only {1} options".format(score_index, len(scores))
|
||||
self.warning(msg)
|
||||
|
||||
# Check the radio button at the correct index
|
||||
else:
|
||||
input_css = (category_css +
|
||||
">li.rubric-list-item:nth-of-type({0}) input.score-selection".format(
|
||||
scores[score_index] + 1)
|
||||
input_css = (
|
||||
category_css +
|
||||
">li.rubric-list-item:nth-of-type({0}) input.score-selection".format(scores[score_index] + 1)
|
||||
)
|
||||
self.css_check(input_css)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""
|
||||
Student progress page
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from ..lms import BASE_URL
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class ProgressPage(PageObject):
|
||||
@@ -7,19 +11,9 @@ class ProgressPage(PageObject):
|
||||
Student progress page.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.progress"
|
||||
name = "lms.progress"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
|
||||
def url(self, course_id=None):
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
return BASE_URL + "/courses/" + course_id + "/progress"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
@@ -79,10 +73,10 @@ class ProgressPage(PageObject):
|
||||
|
||||
# The section titles also contain "n of m possible points" on the second line
|
||||
# We have to remove this to find the right title
|
||||
section_titles = [title.split('\n')[0] for title in section_titles]
|
||||
section_titles = [t.split('\n')[0] for t in section_titles]
|
||||
|
||||
# Some links are blank, so remove them
|
||||
section_titles = [title for title in section_titles if title]
|
||||
section_titles = [t for t in section_titles if t]
|
||||
|
||||
try:
|
||||
# CSS indices are 1-indexed, so add one to the list index
|
||||
@@ -1,5 +1,9 @@
|
||||
"""
|
||||
Registration page (create a new account)
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from ..lms import BASE_URL
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class RegisterPage(PageObject):
|
||||
@@ -7,26 +11,16 @@ class RegisterPage(PageObject):
|
||||
Registration page (create a new account)
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.register"
|
||||
name = "lms.register"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
|
||||
def url(self, course_id=None):
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL for the registration page of a course.
|
||||
Course ID is currently of the form "edx/999/2013_Spring"
|
||||
but this format could change.
|
||||
"""
|
||||
if course_id is None:
|
||||
raise NotImplemented("Must provide a course ID to access about page")
|
||||
raise NotImplementedError("Must provide a course ID to access about page")
|
||||
|
||||
return BASE_URL + "/register?course_id=" + course_id + "&enrollment_action=enroll"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
High-level tab navigation.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise, fulfill_after
|
||||
from ..lms import BASE_URL
|
||||
|
||||
|
||||
class TabNavPage(PageObject):
|
||||
@@ -8,24 +11,14 @@ class TabNavPage(PageObject):
|
||||
High-level tab navigation.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.tab_nav"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
name = "lms.tab_nav"
|
||||
|
||||
def url(self, **kwargs):
|
||||
"""
|
||||
Since tab navigation appears on multiple pages,
|
||||
it doesn't have a particular URL.
|
||||
"""
|
||||
raise NotImplemented
|
||||
raise NotImplementedError
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('ol.course-tabs')
|
||||
@@ -1,8 +1,10 @@
|
||||
import time
|
||||
"""
|
||||
Video player in the courseware.
|
||||
"""
|
||||
|
||||
import time
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise, fulfill_after
|
||||
from ..lms import BASE_URL
|
||||
|
||||
|
||||
class VideoPage(PageObject):
|
||||
@@ -10,23 +12,13 @@ class VideoPage(PageObject):
|
||||
Video player in the courseware.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.video"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
name = "lms.video"
|
||||
|
||||
def url(self):
|
||||
"""
|
||||
Video players aren't associated with a particular URL.
|
||||
"""
|
||||
raise NotImplemented
|
||||
raise NotImplementedError
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('section.xmodule_VideoModule')
|
||||
29
common/test/acceptance/edxapp_pages/studio/asset_index.py
Normal file
29
common/test/acceptance/edxapp_pages/studio/asset_index.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
The Files and Uploads page for a course in Studio
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class AssetIndexPage(PageObject):
|
||||
"""
|
||||
The Files and Uploads page for a course in Studio
|
||||
"""
|
||||
|
||||
name = "studio.uploads"
|
||||
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL to the files and uploads page for a course.
|
||||
`course_id` is a string of the form "org.number.run", and it is required
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/assets/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-uploads')
|
||||
63
common/test/acceptance/edxapp_pages/studio/auto_auth.py
Normal file
63
common/test/acceptance/edxapp_pages/studio/auto_auth.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Auto-auth page (used to automatically log in during testing).
|
||||
"""
|
||||
|
||||
import urllib
|
||||
from bok_choy.page_object import PageObject
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class AutoAuthPage(PageObject):
|
||||
"""
|
||||
The automatic authorization page.
|
||||
When allowed via the django settings file, visiting
|
||||
this url will create a user and log them in.
|
||||
"""
|
||||
|
||||
name = "studio.auto_auth"
|
||||
|
||||
def url(self, username=None, email=None, password=None, staff=None, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
Auto-auth is an end-point for HTTP GET requests.
|
||||
By default, it will create accounts with random user credentials,
|
||||
but you can also specify credentials using querystring parameters.
|
||||
|
||||
`username`, `email`, and `password` are the user's credentials (strings)
|
||||
`staff` is a boolean indicating whether the user is global staff.
|
||||
`course_id` is the ID of the course to enroll the student in.
|
||||
Currently, this has the form "org/number/run"
|
||||
|
||||
Note that "global staff" is NOT the same as course staff.
|
||||
"""
|
||||
|
||||
# The base URL, used for creating a random user
|
||||
url = BASE_URL + "/auto_auth"
|
||||
|
||||
# Create query string parameters if provided
|
||||
params = {}
|
||||
|
||||
if username is not None:
|
||||
params['username'] = username
|
||||
|
||||
if email is not None:
|
||||
params['email'] = email
|
||||
|
||||
if password is not None:
|
||||
params['password'] = password
|
||||
|
||||
if staff is not None:
|
||||
params['staff'] = "true" if staff else "false"
|
||||
|
||||
if course_id is not None:
|
||||
params['course_id'] = course_id
|
||||
|
||||
query_str = urllib.urlencode(params)
|
||||
|
||||
# Append the query string to the base URL
|
||||
if query_str:
|
||||
url += "?" + query_str
|
||||
|
||||
return url
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return True
|
||||
29
common/test/acceptance/edxapp_pages/studio/checklists.py
Normal file
29
common/test/acceptance/edxapp_pages/studio/checklists.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Course checklists page.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class ChecklistsPage(PageObject):
|
||||
"""
|
||||
Course Checklists page.
|
||||
"""
|
||||
|
||||
name = "studio.checklists"
|
||||
|
||||
def url(self, course_id=None): # pylint: disable=W0221
|
||||
"""
|
||||
URL to the checklist page in a course.
|
||||
`course_id` is a string of the form "org.number.run", and it is required
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/checklists/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-checklists')
|
||||
29
common/test/acceptance/edxapp_pages/studio/course_import.py
Normal file
29
common/test/acceptance/edxapp_pages/studio/course_import.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Course Import page.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class ImportPage(PageObject):
|
||||
"""
|
||||
Course Import page.
|
||||
"""
|
||||
|
||||
name = "studio.import"
|
||||
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL for the import page of a course.
|
||||
`course_id` is a string of the form "org.number.run" and is required.
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/import/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-import')
|
||||
29
common/test/acceptance/edxapp_pages/studio/course_info.py
Normal file
29
common/test/acceptance/edxapp_pages/studio/course_info.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Course Updates page.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class CourseUpdatesPage(PageObject):
|
||||
"""
|
||||
Course Updates page.
|
||||
"""
|
||||
|
||||
name = "studio.updates"
|
||||
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL for the course team page of a course.
|
||||
`course_id` is a string of the form "org.number.run" and is required.
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/course_info/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-updates')
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Edit Subsection page in Studio
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
|
||||
class SubsectionPage(PageObject):
|
||||
"""
|
||||
Edit Subsection page in Studio
|
||||
"""
|
||||
|
||||
name = "studio.subsection"
|
||||
|
||||
def url(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-subsection')
|
||||
29
common/test/acceptance/edxapp_pages/studio/edit_tabs.py
Normal file
29
common/test/acceptance/edxapp_pages/studio/edit_tabs.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Static Pages page for a course.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class StaticPagesPage(PageObject):
|
||||
"""
|
||||
Static Pages page for a course.
|
||||
"""
|
||||
|
||||
name = "studio.tabs"
|
||||
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL to the static pages UI in a course.
|
||||
`course_id` is a string of the form "org.number.run", and it is required
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/tabs/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-static-pages')
|
||||
29
common/test/acceptance/edxapp_pages/studio/export.py
Normal file
29
common/test/acceptance/edxapp_pages/studio/export.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Course Export page.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class ExportPage(PageObject):
|
||||
"""
|
||||
Course Export page.
|
||||
"""
|
||||
|
||||
name = "studio.export"
|
||||
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL for the export page of a course.
|
||||
`course_id` is a string of the form "org.number.run" and is required.
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/export/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-export')
|
||||
28
common/test/acceptance/edxapp_pages/studio/helpers.py
Normal file
28
common/test/acceptance/edxapp_pages/studio/helpers.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Helper functions for Studio page objects.
|
||||
"""
|
||||
|
||||
class InvalidCourseID(Exception):
|
||||
"""
|
||||
The course ID does not have the correct format.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def parse_course_id(course_id):
|
||||
"""
|
||||
Parse a `course_id` string of the form "org.number.run"
|
||||
and return the components as a tuple.
|
||||
|
||||
Raises an `InvalidCourseID` exception if the course ID is not in the right format.
|
||||
"""
|
||||
if course_id is None:
|
||||
raise InvalidCourseID("Invalid course ID: '{0}'".format(course_id))
|
||||
|
||||
elements = course_id.split('.')
|
||||
|
||||
# You need at least 3 parts to a course ID: org, number, and run
|
||||
if len(elements) < 3:
|
||||
raise InvalidCourseID("Invalid course ID: '{0}'".format(course_id))
|
||||
|
||||
return tuple(elements)
|
||||
20
common/test/acceptance/edxapp_pages/studio/howitworks.py
Normal file
20
common/test/acceptance/edxapp_pages/studio/howitworks.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Home page for Studio when not logged in.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class HowitworksPage(PageObject):
|
||||
"""
|
||||
Home page for Studio when not logged in.
|
||||
"""
|
||||
|
||||
name = "studio.howitworks"
|
||||
|
||||
def url(self):
|
||||
return BASE_URL + "/howitworks"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-howitworks')
|
||||
20
common/test/acceptance/edxapp_pages/studio/index.py
Normal file
20
common/test/acceptance/edxapp_pages/studio/index.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
My Courses page in Studio
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class DashboardPage(PageObject):
|
||||
"""
|
||||
My Courses page in Studio
|
||||
"""
|
||||
|
||||
name = "studio.dashboard"
|
||||
|
||||
def url(self):
|
||||
return BASE_URL + "/course"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-dashboard')
|
||||
37
common/test/acceptance/edxapp_pages/studio/login.py
Normal file
37
common/test/acceptance/edxapp_pages/studio/login.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Login page for Studio.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from bok_choy.promise import EmptyPromise, fulfill_after
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class LoginPage(PageObject):
|
||||
"""
|
||||
Login page for Studio.
|
||||
"""
|
||||
|
||||
name = "studio.login"
|
||||
|
||||
def url(self):
|
||||
return BASE_URL + "/signin"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-signin')
|
||||
|
||||
def login(self, email, password):
|
||||
"""
|
||||
Attempt to log in using `email` and `password`.
|
||||
"""
|
||||
|
||||
# Ensure that we make it to another page
|
||||
on_next_page = EmptyPromise(
|
||||
lambda: "login" not in self.browser.url,
|
||||
"redirected from the login page"
|
||||
)
|
||||
|
||||
with fulfill_after(on_next_page):
|
||||
self.css_fill('input#email', email)
|
||||
self.css_fill('input#password', password)
|
||||
self.css_click('button#submit')
|
||||
29
common/test/acceptance/edxapp_pages/studio/manage_users.py
Normal file
29
common/test/acceptance/edxapp_pages/studio/manage_users.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Course Team page in Studio.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class CourseTeamPage(PageObject):
|
||||
"""
|
||||
Course Team page in Studio.
|
||||
"""
|
||||
|
||||
name = "studio.team"
|
||||
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL for the course team page of a course.
|
||||
`course_id` is a string of the form "org.number.run" and is required.
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/course_team/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-team')
|
||||
29
common/test/acceptance/edxapp_pages/studio/overview.py
Normal file
29
common/test/acceptance/edxapp_pages/studio/overview.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Course Outline page in Studio.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class CourseOutlinePage(PageObject):
|
||||
"""
|
||||
Course Outline page in Studio.
|
||||
"""
|
||||
|
||||
name = "studio.outline"
|
||||
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL for the course team page of a course.
|
||||
`course_id` is a string of the form "org.number.run" and is required.
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/course/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-outline')
|
||||
29
common/test/acceptance/edxapp_pages/studio/settings.py
Normal file
29
common/test/acceptance/edxapp_pages/studio/settings.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Course Schedule and Details Settings page.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class SettingsPage(PageObject):
|
||||
"""
|
||||
Course Schedule and Details Settings page.
|
||||
"""
|
||||
|
||||
name = "studio.settings"
|
||||
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL for the settings page of a particular course.
|
||||
`course_id` is a string of the form "org.number.run" and is required.
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/settings/details/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-settings')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Course Advanced Settings page
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class AdvancedSettingsPage(PageObject):
|
||||
"""
|
||||
Course Advanced Settings page.
|
||||
"""
|
||||
|
||||
name = "studio.advanced"
|
||||
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL to the advanced setting page in a course.
|
||||
`course_id` is a string of the form "org.number.run", and it is required
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/settings/advanced/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.advanced')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Course Grading Settings page.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class GradingPage(PageObject):
|
||||
"""
|
||||
Course Grading Settings page.
|
||||
"""
|
||||
|
||||
name = "studio.grading"
|
||||
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL for the course team page of a course.
|
||||
`course_id` is a string of the form "org.number.run" and is required.
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/settings/grading/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.grading')
|
||||
16
common/test/acceptance/edxapp_pages/studio/signup.py
Normal file
16
common/test/acceptance/edxapp_pages/studio/signup.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from bok_choy.page_object import PageObject
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class SignupPage(PageObject):
|
||||
"""
|
||||
Signup page for Studio.
|
||||
"""
|
||||
|
||||
name = "studio.signup"
|
||||
|
||||
def url(self):
|
||||
return BASE_URL + "/signup"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-signup')
|
||||
29
common/test/acceptance/edxapp_pages/studio/textbooks.py
Normal file
29
common/test/acceptance/edxapp_pages/studio/textbooks.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Course Textbooks page.
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
from .helpers import parse_course_id
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class TextbooksPage(PageObject):
|
||||
"""
|
||||
Course Textbooks page.
|
||||
"""
|
||||
|
||||
name = "studio.textbooks"
|
||||
|
||||
def url(self, course_id=None): #pylint: disable=W0221
|
||||
"""
|
||||
URL to the textbook UI in a course.
|
||||
`course_id` is a string of the form "org.number.run", and it is required
|
||||
"""
|
||||
_, _, course_run = parse_course_id(course_id)
|
||||
|
||||
return "{0}/textbooks/{1}/branch/draft/block/{2}".format(
|
||||
BASE_URL, course_id, course_run
|
||||
)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-textbooks')
|
||||
19
common/test/acceptance/edxapp_pages/studio/unit.py
Normal file
19
common/test/acceptance/edxapp_pages/studio/unit.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Unit page in Studio
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
|
||||
class UnitPage(PageObject):
|
||||
"""
|
||||
Unit page in Studio
|
||||
"""
|
||||
|
||||
name = "studio.unit"
|
||||
|
||||
def url(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-unit')
|
||||
4
common/test/acceptance/fixtures/__init__.py
Normal file
4
common/test/acceptance/fixtures/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
import os
|
||||
|
||||
# Get the URL of the instance under test
|
||||
STUDIO_BASE_URL = os.environ.get('studio_url', 'http://localhost:8031')
|
||||
30
common/test/acceptance/fixtures/base.py
Normal file
30
common/test/acceptance/fixtures/base.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Base fixtures.
|
||||
"""
|
||||
from bok_choy.web_app_fixture import WebAppFixture
|
||||
from django.core.management import call_command
|
||||
|
||||
|
||||
class DjangoCmdFixture(WebAppFixture):
|
||||
"""
|
||||
Install a fixture by executing a Django management command.
|
||||
"""
|
||||
|
||||
def __init__(self, cmd, *args, **kwargs):
|
||||
"""
|
||||
Configure the fixture to call `cmd` with the specified
|
||||
positional and keyword arguments.
|
||||
"""
|
||||
self._cmd = cmd
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
|
||||
def install(self):
|
||||
"""
|
||||
Call the Django management command.
|
||||
"""
|
||||
# We do not catch exceptions here. Since management commands
|
||||
# execute arbitrary Python code, any exception could be raised.
|
||||
# So it makes sense to let those go all the way up to the test runner,
|
||||
# where they can quickly be found and fixed.
|
||||
call_command(self._cmd, *self._args, **self._kwargs)
|
||||
321
common/test/acceptance/fixtures/course.py
Normal file
321
common/test/acceptance/fixtures/course.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
Fixture to create a course and course components (XBlocks).
|
||||
"""
|
||||
|
||||
import json
|
||||
import datetime
|
||||
from textwrap import dedent
|
||||
import requests
|
||||
from lazy import lazy
|
||||
from bok_choy.web_app_fixture import WebAppFixture, WebAppFixtureError
|
||||
from . import STUDIO_BASE_URL
|
||||
|
||||
|
||||
class StudioApiFixture(WebAppFixture):
|
||||
"""
|
||||
Base class for fixtures that use the Studio restful API.
|
||||
"""
|
||||
|
||||
@lazy
|
||||
def session_cookies(self):
|
||||
"""
|
||||
Log in as a staff user, then return the cookies for the session (as a dict)
|
||||
Raises a `WebAppFixtureError` if the login fails.
|
||||
"""
|
||||
|
||||
# Use auto-auth to retrieve session cookies for a logged in user
|
||||
response = requests.get(STUDIO_BASE_URL + "/auto_auth?staff=true")
|
||||
|
||||
# Return the cookies from the request
|
||||
if response.ok:
|
||||
return {key: val for key, val in response.cookies.items()}
|
||||
|
||||
else:
|
||||
msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code)
|
||||
raise WebAppFixtureError(msg)
|
||||
|
||||
@lazy
|
||||
def headers(self):
|
||||
"""
|
||||
Default HTTP headers dict.
|
||||
"""
|
||||
return {
|
||||
'Content-type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRFToken': self.session_cookies.get('csrftoken', '')
|
||||
}
|
||||
|
||||
|
||||
class XBlockFixtureDesc(object):
|
||||
"""
|
||||
Description of an XBlock, used to configure a course fixture.
|
||||
"""
|
||||
|
||||
def __init__(self, category, display_name, data=None, metadata=None, grader_type=None, publish='make_public'):
|
||||
"""
|
||||
Configure the XBlock to be created by the fixture.
|
||||
These arguments have the same meaning as in the Studio REST API:
|
||||
* `category`
|
||||
* `display_name`
|
||||
* `data`
|
||||
* `metadata`
|
||||
* `grader_type`
|
||||
* `publish`
|
||||
"""
|
||||
self.category = category
|
||||
self.display_name = display_name
|
||||
self.data = data
|
||||
self.metadata = metadata
|
||||
self.grader_type = grader_type
|
||||
self.publish = publish
|
||||
self.children = []
|
||||
|
||||
def add_children(self, *args):
|
||||
"""
|
||||
Add child XBlocks to this XBlock.
|
||||
Each item in `args` is an `XBlockFixtureDescriptor` object.
|
||||
|
||||
Returns the `xblock_desc` instance to allow chaining.
|
||||
"""
|
||||
self.children.extend(args)
|
||||
return self
|
||||
|
||||
def serialize(self, parent_loc=None):
|
||||
"""
|
||||
Return a JSON representation of the XBlock, suitable
|
||||
for sending as POST data to /xblock
|
||||
|
||||
XBlocks are always set to public visibility.
|
||||
"""
|
||||
payload = {
|
||||
'category': self.category,
|
||||
'display_name': self.display_name,
|
||||
'data': self.data,
|
||||
'metadata': self.metadata,
|
||||
'grader_type': self.grader_type,
|
||||
'publish': self.publish
|
||||
}
|
||||
|
||||
if parent_loc is not None:
|
||||
payload['parent_locator'] = parent_loc
|
||||
|
||||
return json.dumps(payload)
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
Return a string representation of the description.
|
||||
Useful for error messages.
|
||||
"""
|
||||
return dedent("""
|
||||
<XBlockFixtureDescriptor:
|
||||
category={0},
|
||||
data={1},
|
||||
metadata={2},
|
||||
grader_type={3},
|
||||
publish={4},
|
||||
children={5}
|
||||
>
|
||||
""").strip().format(
|
||||
self.category, self.data, self.metadata,
|
||||
self.grader_type, self.publish, self.children
|
||||
)
|
||||
|
||||
|
||||
class CourseFixture(StudioApiFixture):
|
||||
"""
|
||||
Fixture for ensuring that a course exists.
|
||||
|
||||
WARNING: This fixture is NOT idempotent. To avoid conflicts
|
||||
between tests, you should use unique course identifiers for each fixture.
|
||||
"""
|
||||
|
||||
def __init__(self, org, number, run, display_name, start_date=None, end_date=None):
|
||||
"""
|
||||
Configure the course fixture to create a course with
|
||||
|
||||
`org`, `number`, `run`, and `display_name` (all unicode).
|
||||
|
||||
`start_date` and `end_date` are datetime objects indicating the course start and end date.
|
||||
The default is for the course to have started in the distant past, which is generally what
|
||||
we want for testing so students can enroll.
|
||||
|
||||
These have the same meaning as in the Studio restful API /course end-point.
|
||||
"""
|
||||
self._course_dict = {
|
||||
'org': org,
|
||||
'number': number,
|
||||
'run': run,
|
||||
'display_name': display_name
|
||||
}
|
||||
|
||||
# Set a default start date to the past, but use Studio's
|
||||
# default for the end date (meaning we don't set it here)
|
||||
if start_date is None:
|
||||
start_date = datetime.datetime(1970, 1, 1)
|
||||
|
||||
self._course_details = {
|
||||
'start_date': start_date.isoformat(),
|
||||
}
|
||||
|
||||
if end_date is not None:
|
||||
self._course_details['end_date'] = end_date.isoformat()
|
||||
|
||||
self._children = []
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
String representation of the course fixture, useful for debugging.
|
||||
"""
|
||||
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 install(self):
|
||||
"""
|
||||
Create the course and XBlocks within the course.
|
||||
This is NOT an idempotent method; if the course already exists, this will
|
||||
raise a `WebAppFixtureError`. You should use unique course identifiers to avoid
|
||||
conflicts between tests.
|
||||
"""
|
||||
self._create_course()
|
||||
self._configure_course()
|
||||
self._create_xblock_children(self._course_loc, self._children)
|
||||
|
||||
@property
|
||||
def _course_loc(self):
|
||||
"""
|
||||
Return the locator string for the course.
|
||||
"""
|
||||
return "{org}.{number}.{run}/branch/draft/block/{run}".format(**self._course_dict)
|
||||
|
||||
def _create_course(self):
|
||||
"""
|
||||
Create the course described in the fixture.
|
||||
"""
|
||||
# If the course already exists, this will respond
|
||||
# with a 200 and an error message, which we ignore.
|
||||
response = requests.post(
|
||||
STUDIO_BASE_URL + '/course',
|
||||
data=self._encode_post_dict(self._course_dict),
|
||||
headers=self.headers,
|
||||
cookies=self.session_cookies
|
||||
)
|
||||
|
||||
try:
|
||||
err = response.json().get('ErrMsg')
|
||||
|
||||
except ValueError:
|
||||
raise WebAppFixtureError(
|
||||
"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 WebAppFixtureError("Could not create course {0}. Error message: '{1}'".format(self, err))
|
||||
|
||||
if not response.ok:
|
||||
raise WebAppFixtureError(
|
||||
"Could not create course {0}. Status was {1}".format(
|
||||
self._course_dict, response.status_code))
|
||||
|
||||
def _configure_course(self):
|
||||
"""
|
||||
Configure course settings (e.g. start and end date)
|
||||
"""
|
||||
url = STUDIO_BASE_URL + '/settings/details/' + self._course_loc
|
||||
|
||||
# First, get the current values
|
||||
response = requests.get(url, headers=self.headers, cookies=self.session_cookies)
|
||||
|
||||
if not response.ok:
|
||||
raise WebAppFixtureError(
|
||||
"Could not retrieve course details. Status was {0}".format(
|
||||
response.status_code))
|
||||
|
||||
try:
|
||||
details = response.json()
|
||||
except ValueError:
|
||||
raise WebAppFixtureError(
|
||||
"Could not decode course details as JSON: '{0}'".format(old_details)
|
||||
)
|
||||
|
||||
# Update the old details with our overrides
|
||||
details.update(self._course_details)
|
||||
|
||||
# POST the updated details to Studio
|
||||
response = requests.post(
|
||||
url, data=self._encode_post_dict(details),
|
||||
headers=self.headers,
|
||||
cookies=self.session_cookies
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise WebAppFixtureError(
|
||||
"Could not update course details to '{0}'. Status was {1}.".format(
|
||||
self._course_details, response.status_code))
|
||||
|
||||
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 the new XBlock
|
||||
response = requests.post(
|
||||
STUDIO_BASE_URL + '/xblock',
|
||||
data=xblock_desc.serialize(parent_loc=parent_loc),
|
||||
headers=self.headers,
|
||||
cookies=self.session_cookies
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code)
|
||||
raise WebAppFixtureError(msg)
|
||||
|
||||
try:
|
||||
loc = response.json().get('locator')
|
||||
|
||||
except ValueError:
|
||||
raise WebAppFixtureError("Could not decode JSON from '{0}'".format(response.content))
|
||||
|
||||
if loc is not None:
|
||||
|
||||
# Configure the XBlock
|
||||
response = requests.post(
|
||||
STUDIO_BASE_URL + '/xblock/' + loc,
|
||||
data=xblock_desc.serialize(),
|
||||
headers=self.headers,
|
||||
cookies=self.session_cookies
|
||||
)
|
||||
|
||||
if response.ok:
|
||||
return loc
|
||||
else:
|
||||
raise WebAppFixtureError("Could not update {0}".format(xblock_desc))
|
||||
|
||||
else:
|
||||
raise WebAppFixtureError("Could not retrieve location of {0}".format(xblock_desc))
|
||||
|
||||
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 v is not None else v
|
||||
for k, v in post_dict.items()
|
||||
})
|
||||
10
common/test/acceptance/tests/__init__.py
Normal file
10
common/test/acceptance/tests/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import logging
|
||||
|
||||
# Silence noisy loggers
|
||||
LOG_OVERRIDES = [
|
||||
('requests.packages.urllib3.connectionpool', logging.ERROR),
|
||||
('django.db.backends', logging.ERROR)
|
||||
]
|
||||
|
||||
for log_name, log_level in LOG_OVERRIDES:
|
||||
logging.getLogger(log_name).setLevel(log_level)
|
||||
30
common/test/acceptance/tests/data/ora_ai_problem.xml
Normal file
30
common/test/acceptance/tests/data/ora_ai_problem.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<combinedopenended max_score="2" markdown="null" max_attempts="1000">
|
||||
<rubric>
|
||||
<rubric>
|
||||
<category>
|
||||
<description>Writing Applications</description>
|
||||
<option> The essay loses focus, has little information or supporting details, and the organization makes it difficult to follow.</option>
|
||||
<option> The essay presents a mostly unified theme, includes sufficient information to convey the theme, and is generally organized well.</option>
|
||||
</category>
|
||||
<category>
|
||||
<description> Language Conventions </description>
|
||||
<option> The essay demonstrates a reasonable command of proper spelling and grammar. </option>
|
||||
<option> The essay demonstrates superior command of proper spelling and grammar.</option>
|
||||
</category>
|
||||
</rubric>
|
||||
</rubric>
|
||||
<prompt>
|
||||
<h4>Censorship in the Libraries</h4>
|
||||
<p>"All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us." --Katherine Paterson, Author</p>
|
||||
<p>Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.</p>
|
||||
</prompt>
|
||||
<task>
|
||||
<openended>
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>
|
||||
</task>
|
||||
</combinedopenended>
|
||||
24
common/test/acceptance/tests/data/ora_self_problem.xml
Normal file
24
common/test/acceptance/tests/data/ora_self_problem.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<combinedopenended max_score="1" accept_file_upload="False" markdown="null" max_attempts="1000" skip_spelling_checks="False" version="1">
|
||||
<rubric>
|
||||
<rubric>
|
||||
<category>
|
||||
<description>Writing Applications</description>
|
||||
<option> The essay loses focus, has little information or supporting details, and the organization makes it difficult to follow.</option>
|
||||
<option> The essay presents a mostly unified theme, includes sufficient information to convey the theme, and is generally organized well.</option>
|
||||
</category>
|
||||
<category>
|
||||
<description> Language Conventions </description>
|
||||
<option> The essay demonstrates a reasonable command of proper spelling and grammar. </option>
|
||||
<option> The essay demonstrates superior command of proper spelling and grammar.</option>
|
||||
</category>
|
||||
</rubric>
|
||||
</rubric>
|
||||
<prompt>
|
||||
<h4>Censorship in the Libraries</h4>
|
||||
<p>"All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us." --Katherine Paterson, Author</p>
|
||||
<p>Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.</p>
|
||||
</prompt>
|
||||
<task>
|
||||
<selfassessment/>
|
||||
</task>
|
||||
</combinedopenended>
|
||||
14
common/test/acceptance/tests/helpers.py
Normal file
14
common/test/acceptance/tests/helpers.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Test helper functions.
|
||||
"""
|
||||
from path import path
|
||||
|
||||
|
||||
def load_data_str(rel_path):
|
||||
"""
|
||||
Load a file from the "data" directory as a string.
|
||||
`rel_path` is the path relative to the data directory.
|
||||
"""
|
||||
full_path = path(__file__).abspath().dirname() / "data" / rel_path #pylint: disable=E1120
|
||||
with open(full_path) as data_file:
|
||||
return data_file.read()
|
||||
132
common/test/acceptance/tests/test_ora.py
Normal file
132
common/test/acceptance/tests/test_ora.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Tests for ORA (Open Response Assessment) through the LMS UI.
|
||||
"""
|
||||
|
||||
from bok_choy.web_app_test import WebAppTest
|
||||
from ..edxapp_pages.studio.auto_auth import AutoAuthPage
|
||||
from ..edxapp_pages.lms.course_info import CourseInfoPage
|
||||
from ..edxapp_pages.lms.tab_nav import TabNavPage
|
||||
from ..edxapp_pages.lms.course_nav import CourseNavPage
|
||||
from ..edxapp_pages.lms.open_response import OpenResponsePage
|
||||
from ..fixtures.course import XBlockFixtureDesc, CourseFixture
|
||||
|
||||
from .helpers import load_data_str
|
||||
|
||||
|
||||
class OpenResponseTest(WebAppTest):
|
||||
"""
|
||||
Tests that interact with ORA (Open Response Assessment) through the LMS UI.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Always start in the subsection with open response problems.
|
||||
"""
|
||||
|
||||
# Create a unique course ID
|
||||
self.course_info = {
|
||||
'org': 'test_org',
|
||||
'number': self.unique_id,
|
||||
'run': 'test_run',
|
||||
'display_name': 'Test Course' + self.unique_id
|
||||
}
|
||||
|
||||
# Ensure fixtures are installed
|
||||
super(OpenResponseTest, self).setUp()
|
||||
|
||||
# Log in and navigate to the essay problems
|
||||
course_id = '{org}/{number}/{run}'.format(**self.course_info)
|
||||
self.ui.visit('studio.auto_auth', course_id=course_id)
|
||||
self.ui.visit('lms.course_info', course_id=course_id)
|
||||
self.ui['lms.tab_nav'].go_to_tab('Courseware')
|
||||
self.ui['lms.course_nav'].go_to_section(
|
||||
'Example Week 2: Get Interactive', 'Homework - Essays'
|
||||
)
|
||||
|
||||
@property
|
||||
def page_object_classes(self):
|
||||
return [AutoAuthPage, CourseInfoPage, TabNavPage, CourseNavPage, OpenResponsePage]
|
||||
|
||||
@property
|
||||
def fixtures(self):
|
||||
"""
|
||||
Create a test course with open response problems.
|
||||
"""
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('combinedopenended', 'Self-Assessed', data=load_data_str('ora_self_problem.xml')),
|
||||
XBlockFixtureDesc('combinedopenended', 'AI-Assessed', data=load_data_str('ora_ai_problem.xml'))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return [course_fix]
|
||||
|
||||
def test_self_assessment(self):
|
||||
"""
|
||||
Test that the user can self-assess an essay.
|
||||
"""
|
||||
# Navigate to the self-assessment problem and submit an essay
|
||||
self.ui['lms.course_nav'].go_to_sequential('Self-Assessed')
|
||||
self._submit_essay('self', 'Censorship in the Libraries')
|
||||
|
||||
# Check the rubric categories
|
||||
self.assertEqual(
|
||||
self.ui['lms.open_response'].rubric_categories,
|
||||
["Writing Applications", "Language Conventions"]
|
||||
)
|
||||
|
||||
# Fill in the self-assessment rubric
|
||||
self.ui['lms.open_response'].submit_self_assessment([0, 1])
|
||||
|
||||
# Expect that we get feedback
|
||||
self.assertEqual(
|
||||
self.ui['lms.open_response'].rubric_feedback,
|
||||
['incorrect', 'correct']
|
||||
)
|
||||
|
||||
def test_ai_assessment(self):
|
||||
"""
|
||||
Test that a user can submit an essay and receive AI feedback.
|
||||
"""
|
||||
|
||||
# Navigate to the AI-assessment problem and submit an essay
|
||||
self.ui['lms.course_nav'].go_to_sequential('AI-Assessed')
|
||||
self._submit_essay('ai', 'Censorship in the Libraries')
|
||||
|
||||
# Expect UI feedback that the response was submitted
|
||||
self.assertEqual(
|
||||
self.ui['lms.open_response'].grader_status,
|
||||
"Your response has been submitted. Please check back later for your grade."
|
||||
)
|
||||
|
||||
def _submit_essay(self, expected_assessment_type, expected_prompt):
|
||||
"""
|
||||
Submit an essay and verify that the problem uses
|
||||
the `expected_assessment_type` ("self", "ai", or "peer") and
|
||||
shows the `expected_prompt` (a string).
|
||||
"""
|
||||
|
||||
# Check the assessment type and prompt
|
||||
self.assertEqual(self.ui['lms.open_response'].assessment_type, expected_assessment_type)
|
||||
self.assertIn(expected_prompt, self.ui['lms.open_response'].prompt)
|
||||
|
||||
# Enter a response
|
||||
essay = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut vehicula."
|
||||
self.ui['lms.open_response'].set_response(essay)
|
||||
|
||||
# Save the response and expect some UI feedback
|
||||
self.ui['lms.open_response'].save_response()
|
||||
self.assertEqual(
|
||||
self.ui['lms.open_response'].alert_message,
|
||||
"Answer saved, but not yet submitted."
|
||||
)
|
||||
|
||||
# Submit the response
|
||||
self.ui['lms.open_response'].submit_response()
|
||||
118
common/test/acceptance/tests/test_studio.py
Normal file
118
common/test/acceptance/tests/test_studio.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Acceptance tests for Studio.
|
||||
"""
|
||||
from bok_choy.web_app_test import WebAppTest
|
||||
|
||||
from ..edxapp_pages.studio.asset_index import AssetIndexPage
|
||||
from ..edxapp_pages.studio.auto_auth import AutoAuthPage
|
||||
from ..edxapp_pages.studio.checklists import ChecklistsPage
|
||||
from ..edxapp_pages.studio.course_import import ImportPage
|
||||
from ..edxapp_pages.studio.course_info import CourseUpdatesPage
|
||||
from ..edxapp_pages.studio.edit_tabs import StaticPagesPage
|
||||
from ..edxapp_pages.studio.export import ExportPage
|
||||
from ..edxapp_pages.studio.howitworks import HowitworksPage
|
||||
from ..edxapp_pages.studio.index import DashboardPage
|
||||
from ..edxapp_pages.studio.login import LoginPage
|
||||
from ..edxapp_pages.studio.manage_users import CourseTeamPage
|
||||
from ..edxapp_pages.studio.overview import CourseOutlinePage
|
||||
from ..edxapp_pages.studio.settings import SettingsPage
|
||||
from ..edxapp_pages.studio.settings_advanced import AdvancedSettingsPage
|
||||
from ..edxapp_pages.studio.settings_graders import GradingPage
|
||||
from ..edxapp_pages.studio.signup import SignupPage
|
||||
from ..edxapp_pages.studio.textbooks import TextbooksPage
|
||||
from ..fixtures.course import CourseFixture
|
||||
|
||||
|
||||
class LoggedOutTest(WebAppTest):
|
||||
"""
|
||||
Smoke test for pages in Studio that are visible when logged out.
|
||||
"""
|
||||
|
||||
@property
|
||||
def page_object_classes(self):
|
||||
return [LoginPage, HowitworksPage, SignupPage]
|
||||
|
||||
def test_page_existence(self):
|
||||
"""
|
||||
Make sure that all the pages are accessible.
|
||||
Rather than fire up the browser just to check each url,
|
||||
do them all sequentially in this testcase.
|
||||
"""
|
||||
for page in ['login', 'howitworks', 'signup']:
|
||||
self.ui.visit('studio.{0}'.format(page))
|
||||
|
||||
|
||||
class LoggedInPagesTest(WebAppTest):
|
||||
"""
|
||||
Tests that verify the pages in Studio that you can get to when logged
|
||||
in and do not have a course yet.
|
||||
"""
|
||||
@property
|
||||
def page_object_classes(self):
|
||||
return [AutoAuthPage, DashboardPage]
|
||||
|
||||
def test_dashboard_no_courses(self):
|
||||
"""
|
||||
Make sure that you can get to the dashboard page without a course.
|
||||
"""
|
||||
self.ui.visit('studio.auto_auth', staff=True)
|
||||
self.ui.visit('studio.dashboard')
|
||||
|
||||
|
||||
class CoursePagesTest(WebAppTest):
|
||||
"""
|
||||
Tests that verify the pages in Studio that you can get to when logged
|
||||
in and have a course.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a unique identifier for the course used in this test.
|
||||
"""
|
||||
# Define a unique course identifier
|
||||
self.course_info = {
|
||||
'org': 'test_org',
|
||||
'number': '101',
|
||||
'run': 'test_' + self.unique_id,
|
||||
'display_name': 'Test Course ' + self.unique_id
|
||||
}
|
||||
|
||||
# Ensure that the superclass sets up
|
||||
super(CoursePagesTest, self).setUp()
|
||||
|
||||
@property
|
||||
def page_object_classes(self):
|
||||
return [
|
||||
AutoAuthPage, AssetIndexPage, ChecklistsPage, ImportPage, CourseUpdatesPage,
|
||||
StaticPagesPage, ExportPage, CourseTeamPage, CourseOutlinePage,
|
||||
SettingsPage, AdvancedSettingsPage, GradingPage, TextbooksPage
|
||||
]
|
||||
|
||||
@property
|
||||
def fixtures(self):
|
||||
super_fixtures = super(CoursePagesTest, self).fixtures
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
return set(super_fixtures + [course_fix])
|
||||
|
||||
def test_page_existence(self):
|
||||
"""
|
||||
Make sure that all these pages are accessible once you have a course.
|
||||
Rather than fire up the browser just to check each url,
|
||||
do them all sequentially in this testcase.
|
||||
"""
|
||||
pages = [
|
||||
'uploads', 'checklists', 'import', 'updates', 'tabs', 'export',
|
||||
'team', 'outline', 'settings', 'advanced', 'grading', 'textbooks'
|
||||
]
|
||||
|
||||
# Log in
|
||||
self.ui.visit('studio.auto_auth', staff=True)
|
||||
|
||||
course_id = '{org}.{number}.{run}'.format(**self.course_info)
|
||||
for page in pages:
|
||||
self.ui.visit('studio.{0}'.format(page), course_id=course_id)
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
from bok_choy.page_object import PageObject
|
||||
from ..lms import BASE_URL
|
||||
|
||||
|
||||
class InfoPage(PageObject):
|
||||
"""
|
||||
Info pages for the main site.
|
||||
These are basically static pages, so we use one page
|
||||
object to represent them all.
|
||||
"""
|
||||
|
||||
# Dictionary mapping section names to URL paths
|
||||
SECTION_PATH = {
|
||||
'about': '/about',
|
||||
'faq': '/faq',
|
||||
'press': '/press',
|
||||
'contact': '/contact',
|
||||
'terms': '/tos',
|
||||
'privacy': '/privacy',
|
||||
'honor': '/honor',
|
||||
}
|
||||
|
||||
# Dictionary mapping URLs to expected css selector
|
||||
EXPECTED_CSS = {
|
||||
'/about': 'section.vision',
|
||||
'/faq': 'section.faq',
|
||||
'/press': 'section.press',
|
||||
'/contact': 'section.contact',
|
||||
'/tos': 'section.tos',
|
||||
'/privacy': 'section.privacy-policy',
|
||||
'/honor': 'section.honor-code',
|
||||
}
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "lms.info"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
|
||||
def url(self, section=None):
|
||||
return BASE_URL + self.SECTION_PATH[section]
|
||||
|
||||
def is_browser_on_page(self):
|
||||
|
||||
# Find the appropriate css based on the URL
|
||||
for url_path, css_sel in self.EXPECTED_CSS.iteritems():
|
||||
if self.browser.url.endswith(url_path):
|
||||
return self.is_css_present(css_sel)
|
||||
|
||||
# Could not find the CSS based on the URL
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def sections(cls):
|
||||
return cls.SECTION_PATH.keys()
|
||||
@@ -1,26 +0,0 @@
|
||||
from bok_choy.page_object import PageObject
|
||||
from ..studio import BASE_URL
|
||||
|
||||
|
||||
class HowitworksPage(PageObject):
|
||||
"""
|
||||
Home page for Studio when not logged in.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "studio.howitworks"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
|
||||
def url(self):
|
||||
return BASE_URL + "/howitworks"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.browser.title == 'Welcome | edX Studio'
|
||||
@@ -1,34 +0,0 @@
|
||||
from bok_choy.page_object import PageObject
|
||||
from ..studio import BASE_URL
|
||||
|
||||
|
||||
class LoginPage(PageObject):
|
||||
"""
|
||||
Login page for Studio.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "studio.login"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
|
||||
def url(self):
|
||||
return BASE_URL + "/signin"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.browser.title == 'Sign In | edX Studio'
|
||||
|
||||
def login(self, email, password):
|
||||
"""
|
||||
Attempt to log in using `email` and `password`.
|
||||
"""
|
||||
self.css_fill('input#email', email)
|
||||
self.css_fill('input#password', password)
|
||||
self.css_click('button#submit')
|
||||
@@ -1,26 +0,0 @@
|
||||
from bok_choy.page_object import PageObject
|
||||
from ..studio import BASE_URL
|
||||
|
||||
|
||||
class SignupPage(PageObject):
|
||||
"""
|
||||
Signup page for Studio.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "studio.signup"
|
||||
|
||||
@property
|
||||
def requirejs(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def js_globals(self):
|
||||
return []
|
||||
|
||||
def url(self):
|
||||
return BASE_URL + "/signup"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.browser.title == 'Sign Up | edX Studio'
|
||||
@@ -1,20 +0,0 @@
|
||||
"""
|
||||
Very simple test case to verify bok-choy integration.
|
||||
"""
|
||||
|
||||
from bok_choy.web_app_test import WebAppTest
|
||||
from edxapp_pages.lms.info import InfoPage
|
||||
|
||||
|
||||
class InfoPageTest(WebAppTest):
|
||||
"""
|
||||
Test that the top-level pages in the LMS load.
|
||||
"""
|
||||
|
||||
@property
|
||||
def page_object_classes(self):
|
||||
return [InfoPage]
|
||||
|
||||
def test_info(self):
|
||||
for section_name in InfoPage.sections():
|
||||
self.ui.visit('lms.info', section=section_name)
|
||||
@@ -1,10 +1,12 @@
|
||||
# Settings for bok choy tests
|
||||
"""
|
||||
Settings for bok choy tests
|
||||
"""
|
||||
|
||||
import os
|
||||
from path import path
|
||||
|
||||
|
||||
CONFIG_ROOT = path(__file__).abspath().dirname()
|
||||
CONFIG_ROOT = path(__file__).abspath().dirname() #pylint: disable=E1120
|
||||
TEST_ROOT = CONFIG_ROOT.dirname().dirname() / "test_root"
|
||||
|
||||
########################## Prod-like settings ###################################
|
||||
@@ -16,7 +18,7 @@ TEST_ROOT = CONFIG_ROOT.dirname().dirname() / "test_root"
|
||||
os.environ['SERVICE_VARIANT'] = 'bok_choy'
|
||||
os.environ['CONFIG_ROOT'] = CONFIG_ROOT
|
||||
|
||||
from aws import * # pylint: disable=W0401, W0614
|
||||
from .aws import * # pylint: disable=W0401, W0614
|
||||
|
||||
|
||||
######################### Testing overrides ####################################
|
||||
@@ -45,7 +47,8 @@ import logging
|
||||
LOG_OVERRIDES = [
|
||||
('track.middleware', logging.CRITICAL),
|
||||
('edxmako.shortcuts', logging.ERROR),
|
||||
('dd.dogapi', logging.ERROR)
|
||||
('dd.dogapi', logging.ERROR),
|
||||
('edx.discussion', logging.CRITICAL),
|
||||
]
|
||||
for log_name, log_level in LOG_OVERRIDES:
|
||||
logging.getLogger(log_name).setLevel(log_level)
|
||||
|
||||
@@ -12,8 +12,7 @@ BOK_CHOY_NUM_PARALLEL = ENV.fetch('NUM_PARALLEL', 1).to_i
|
||||
BOK_CHOY_TEST_TIMEOUT = ENV.fetch("TEST_TIMEOUT", 300).to_f
|
||||
|
||||
# Ensure that we have a directory to put logs and reports
|
||||
BOK_CHOY_DIR = File.join(REPO_ROOT, "common", "test", "bok_choy")
|
||||
BOK_CHOY_TEST_DIR = File.join(BOK_CHOY_DIR, "tests")
|
||||
BOK_CHOY_TEST_DIR = File.join(REPO_ROOT, "common", "test", "acceptance", "tests")
|
||||
BOK_CHOY_LOG_DIR = File.join(REPO_ROOT, "test_root", "log")
|
||||
directory BOK_CHOY_LOG_DIR
|
||||
|
||||
@@ -76,7 +75,7 @@ end
|
||||
|
||||
|
||||
def nose_cmd(test_spec)
|
||||
cmd = ["PYTHONPATH=#{BOK_CHOY_DIR}:$PYTHONPATH", "SCREENSHOT_DIR=#{BOK_CHOY_LOG_DIR}", "nosetests", test_spec]
|
||||
cmd = ["SCREENSHOT_DIR='#{BOK_CHOY_LOG_DIR}'", "nosetests", test_spec]
|
||||
if BOK_CHOY_NUM_PARALLEL > 1
|
||||
cmd += ["--processes=#{BOK_CHOY_NUM_PARALLEL}", "--process-timeout=#{BOK_CHOY_TEST_TIMEOUT}"]
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user