Merge pull request #2504 from edx/gprice/forum-pagination-acceptance
Add acceptance tests for forum response pagination
This commit is contained in:
79
common/djangoapps/terrain/stubs/comments.py
Normal file
79
common/djangoapps/terrain/stubs/comments.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Stub implementation of cs_comments_service for acceptance tests
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import re
|
||||
import urlparse
|
||||
from .http import StubHttpRequestHandler, StubHttpService
|
||||
|
||||
|
||||
class StubCommentsServiceHandler(StubHttpRequestHandler):
|
||||
def do_GET(self):
|
||||
pattern_handlers = {
|
||||
"/api/v1/users/(?P<user_id>\\d+)$": self.do_user,
|
||||
"/api/v1/threads$": self.do_threads,
|
||||
"/api/v1/threads/(?P<thread_id>\\w+)$": self.do_thread,
|
||||
}
|
||||
path = urlparse.urlparse(self.path).path
|
||||
for pattern in pattern_handlers:
|
||||
match = re.match(pattern, path)
|
||||
if match:
|
||||
pattern_handlers[pattern](**match.groupdict())
|
||||
return
|
||||
|
||||
self.send_response(404, content="404 Not Found")
|
||||
|
||||
def do_PUT(self):
|
||||
self.send_response(204, "")
|
||||
|
||||
def do_user(self, user_id):
|
||||
self.send_json_response({
|
||||
"id": user_id,
|
||||
"upvoted_ids": [],
|
||||
"downvoted_ids": [],
|
||||
"subscribed_thread_ids": [],
|
||||
})
|
||||
|
||||
def do_thread(self, thread_id):
|
||||
match = re.search("(?P<num>\\d+)_responses", thread_id)
|
||||
resp_total = int(match.group("num")) if match else 0
|
||||
thread = {
|
||||
"id": thread_id,
|
||||
"commentable_id": "dummy",
|
||||
"type": "thread",
|
||||
"title": "Thread title",
|
||||
"body": "Thread body",
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"unread_comments_count": 0,
|
||||
"comments_count": resp_total,
|
||||
"votes": {"up_count": 0},
|
||||
"abuse_flaggers": [],
|
||||
"closed": "closed" in thread_id,
|
||||
}
|
||||
params = urlparse.parse_qs(urlparse.urlparse(self.path).query)
|
||||
if "recursive" in params and params["recursive"][0] == "True":
|
||||
thread["resp_total"] = resp_total
|
||||
thread["children"] = []
|
||||
resp_skip = int(params.get("resp_skip", ["0"])[0])
|
||||
resp_limit = int(params.get("resp_limit", ["10000"])[0])
|
||||
num_responses = min(resp_limit, resp_total - resp_skip)
|
||||
self.log_message("Generating {} children; resp_limit={} resp_total={} resp_skip={}".format(num_responses, resp_limit, resp_total, resp_skip))
|
||||
for i in range(num_responses):
|
||||
response_id = str(resp_skip + i)
|
||||
thread["children"].append({
|
||||
"id": str(response_id),
|
||||
"type": "comment",
|
||||
"body": response_id,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"votes": {"up_count": 0},
|
||||
"abuse_flaggers": [],
|
||||
})
|
||||
self.send_json_response(thread)
|
||||
|
||||
def do_threads(self):
|
||||
self.send_json_response({"collection": [], "page": 1, "num_pages": 1})
|
||||
|
||||
|
||||
class StubCommentsService(StubHttpService):
|
||||
HANDLER_CLASS = StubCommentsServiceHandler
|
||||
@@ -202,6 +202,13 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object):
|
||||
if content is not None:
|
||||
self.wfile.write(content)
|
||||
|
||||
def send_json_response(self, content):
|
||||
"""
|
||||
Send a response with status code 200, the given content serialized as
|
||||
JSON, and the Content-Type header set appropriately
|
||||
"""
|
||||
self.send_response(200, json.dumps(content), {"Content-Type": "application/json"})
|
||||
|
||||
def _format_msg(self, format_str, *args):
|
||||
"""
|
||||
Format message for logging.
|
||||
|
||||
@@ -4,6 +4,7 @@ Command-line utility to start a stub service.
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from .comments import StubCommentsService
|
||||
from .xqueue import StubXQueueService
|
||||
from .youtube import StubYouTubeService
|
||||
from .ora import StubOraService
|
||||
@@ -14,7 +15,8 @@ USAGE = "USAGE: python -m stubs.start SERVICE_NAME PORT_NUM [CONFIG_KEY=CONFIG_V
|
||||
SERVICES = {
|
||||
'xqueue': StubXQueueService,
|
||||
'youtube': StubYouTubeService,
|
||||
'ora': StubOraService
|
||||
'ora': StubOraService,
|
||||
'comments': StubCommentsService,
|
||||
}
|
||||
|
||||
# Log to stdout, including debug messages
|
||||
|
||||
@@ -11,7 +11,7 @@ class CourseAboutPage(CoursePage):
|
||||
Course about page (with registration button)
|
||||
"""
|
||||
|
||||
URL_PATH = "about"
|
||||
url_path = "about"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('section.course-info')
|
||||
|
||||
@@ -10,7 +10,7 @@ class CourseInfoPage(CoursePage):
|
||||
Course info.
|
||||
"""
|
||||
|
||||
URL_PATH = "info"
|
||||
url_path = "info"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('section.updates')
|
||||
|
||||
@@ -13,7 +13,7 @@ class CoursePage(PageObject):
|
||||
|
||||
# Overridden by subclasses to provide the relative path within the course
|
||||
# Paths should not include the leading forward slash.
|
||||
URL_PATH = ""
|
||||
url_path = ""
|
||||
|
||||
def __init__(self, browser, course_id):
|
||||
"""
|
||||
@@ -28,4 +28,4 @@ class CoursePage(PageObject):
|
||||
"""
|
||||
Construct a URL to the page within the course.
|
||||
"""
|
||||
return BASE_URL + "/courses/" + self.course_id + "/" + self.URL_PATH
|
||||
return BASE_URL + "/courses/" + self.course_id + "/" + self.url_path
|
||||
|
||||
70
common/test/acceptance/pages/lms/discussion_single_thread.py
Normal file
70
common/test/acceptance/pages/lms/discussion_single_thread.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from bok_choy.page_object import unguarded
|
||||
from bok_choy.promise import EmptyPromise, fulfill
|
||||
|
||||
from .course_page import CoursePage
|
||||
|
||||
|
||||
class DiscussionSingleThreadPage(CoursePage):
|
||||
def __init__(self, browser, course_id, thread_id):
|
||||
super(DiscussionSingleThreadPage, self).__init__(browser, course_id)
|
||||
self.thread_id = thread_id
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present(
|
||||
"body.discussion .discussion-article[data-id='{thread_id}']".format(thread_id=self.thread_id)
|
||||
)
|
||||
|
||||
@property
|
||||
@unguarded
|
||||
def url_path(self):
|
||||
return "discussion/forum/dummy/threads/" + self.thread_id
|
||||
|
||||
def _get_element_text(self, selector):
|
||||
"""
|
||||
Returns the text of the first element matching the given selector, or
|
||||
None if no such element exists
|
||||
"""
|
||||
text_list = self.css_text(selector)
|
||||
return text_list[0] if text_list else None
|
||||
|
||||
def get_response_total_text(self):
|
||||
"""Returns the response count text, or None if not present"""
|
||||
return self._get_element_text(".response-count")
|
||||
|
||||
def get_num_displayed_responses(self):
|
||||
"""Returns the number of responses actually rendered"""
|
||||
return self.css_count(".discussion-response")
|
||||
|
||||
def get_shown_responses_text(self):
|
||||
"""Returns the shown response count text, or None if not present"""
|
||||
return self._get_element_text(".response-display-count")
|
||||
|
||||
def get_load_responses_button_text(self):
|
||||
"""Returns the load more responses button text, or None if not present"""
|
||||
return self._get_element_text(".load-response-button")
|
||||
|
||||
def load_more_responses(self):
|
||||
"""Clicks the laod more responses button and waits for responses to load"""
|
||||
self.css_click(".load-response-button")
|
||||
fulfill(EmptyPromise(
|
||||
lambda: not self.is_css_present(".loading"),
|
||||
"Loading more responses completed"
|
||||
))
|
||||
|
||||
def has_add_response_button(self):
|
||||
"""Returns true if the add response button is visible, false otherwise"""
|
||||
return (
|
||||
self.is_css_present(".add-response-btn") and
|
||||
self.css_map(".add-response-btn", lambda el: el.visible)[0]
|
||||
)
|
||||
|
||||
def click_add_response_button(self):
|
||||
"""
|
||||
Clicks the add response button and ensures that the response text
|
||||
field receives focus
|
||||
"""
|
||||
self.css_click(".add-response-btn")
|
||||
fulfill(EmptyPromise(
|
||||
lambda: self.is_css_present("#wmd-input-reply-body-{thread_id}:focus".format(thread_id=self.thread_id)),
|
||||
"Response field received focus"
|
||||
))
|
||||
@@ -10,7 +10,7 @@ class ProgressPage(CoursePage):
|
||||
Student progress page.
|
||||
"""
|
||||
|
||||
URL_PATH = "progress"
|
||||
url_path = "progress"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
has_course_info = self.is_css_present('div.course-info')
|
||||
|
||||
@@ -10,7 +10,7 @@ class AssetIndexPage(CoursePage):
|
||||
The Files and Uploads page for a course in Studio
|
||||
"""
|
||||
|
||||
URL_PATH = "assets"
|
||||
url_path = "assets"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-uploads')
|
||||
|
||||
@@ -10,7 +10,7 @@ class ChecklistsPage(CoursePage):
|
||||
Course Checklists page.
|
||||
"""
|
||||
|
||||
URL_PATH = "checklists"
|
||||
url_path = "checklists"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-checklists')
|
||||
|
||||
@@ -10,7 +10,7 @@ class ImportPage(CoursePage):
|
||||
Course Import page.
|
||||
"""
|
||||
|
||||
URL_PATH = "import"
|
||||
url_path = "import"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-import')
|
||||
|
||||
@@ -10,7 +10,7 @@ class CourseUpdatesPage(CoursePage):
|
||||
Course Updates page.
|
||||
"""
|
||||
|
||||
URL_PATH = "course_info"
|
||||
url_path = "course_info"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-updates')
|
||||
|
||||
@@ -13,7 +13,7 @@ class CoursePage(PageObject):
|
||||
|
||||
# Overridden by subclasses to provide the relative path within the course
|
||||
# Does not need to include the leading forward or trailing slash
|
||||
URL_PATH = ""
|
||||
url_path = ""
|
||||
|
||||
def __init__(self, browser, course_org, course_num, course_run):
|
||||
"""
|
||||
@@ -35,7 +35,7 @@ class CoursePage(PageObject):
|
||||
Construct a URL to the page within the course.
|
||||
"""
|
||||
return "/".join([
|
||||
BASE_URL, self.URL_PATH,
|
||||
BASE_URL, self.url_path,
|
||||
"{course_org}.{course_num}.{course_run}".format(**self.course_info),
|
||||
"branch", "draft", "block", self.course_info['course_run']
|
||||
])
|
||||
|
||||
@@ -10,7 +10,7 @@ class StaticPagesPage(CoursePage):
|
||||
Static Pages page for a course.
|
||||
"""
|
||||
|
||||
URL_PATH = "tabs"
|
||||
url_path = "tabs"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-static-pages')
|
||||
|
||||
@@ -10,7 +10,7 @@ class ExportPage(CoursePage):
|
||||
Course Export page.
|
||||
"""
|
||||
|
||||
URL_PATH = "export"
|
||||
url_path = "export"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-export')
|
||||
|
||||
@@ -10,7 +10,7 @@ class CourseTeamPage(CoursePage):
|
||||
Course Team page in Studio.
|
||||
"""
|
||||
|
||||
URL_PATH = "course_team"
|
||||
url_path = "course_team"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-team')
|
||||
|
||||
@@ -10,7 +10,7 @@ class CourseOutlinePage(CoursePage):
|
||||
Course Outline page in Studio.
|
||||
"""
|
||||
|
||||
URL_PATH = "course"
|
||||
url_path = "course"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-outline')
|
||||
|
||||
@@ -10,7 +10,7 @@ class SettingsPage(CoursePage):
|
||||
Course Schedule and Details Settings page.
|
||||
"""
|
||||
|
||||
URL_PATH = "settings/details"
|
||||
url_path = "settings/details"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-settings')
|
||||
|
||||
@@ -10,7 +10,7 @@ class AdvancedSettingsPage(CoursePage):
|
||||
Course Advanced Settings page.
|
||||
"""
|
||||
|
||||
URL_PATH = "settings/advanced"
|
||||
url_path = "settings/advanced"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.advanced')
|
||||
|
||||
@@ -10,7 +10,7 @@ class GradingPage(CoursePage):
|
||||
Course Grading Settings page.
|
||||
"""
|
||||
|
||||
URL_PATH = "settings/grading"
|
||||
url_path = "settings/grading"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.grading')
|
||||
|
||||
@@ -10,7 +10,7 @@ class TextbooksPage(CoursePage):
|
||||
Course Textbooks page.
|
||||
"""
|
||||
|
||||
URL_PATH = "textbooks"
|
||||
url_path = "textbooks"
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('body.view-textbooks')
|
||||
|
||||
81
common/test/acceptance/tests/test_discussion.py
Normal file
81
common/test/acceptance/tests/test_discussion.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Tests for discussion pages
|
||||
"""
|
||||
|
||||
from .helpers import UniqueCourseTest
|
||||
from ..pages.studio.auto_auth import AutoAuthPage
|
||||
from ..pages.lms.discussion_single_thread import DiscussionSingleThreadPage
|
||||
from ..fixtures.course import CourseFixture
|
||||
|
||||
|
||||
class DiscussionSingleThreadTest(UniqueCourseTest):
|
||||
"""
|
||||
Tests for the discussion page displaying a single thread
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(DiscussionSingleThreadTest, self).setUp()
|
||||
|
||||
# Create a course to register for
|
||||
CourseFixture(**self.course_info).install()
|
||||
|
||||
AutoAuthPage(self.browser, course_id=self.course_id).visit()
|
||||
|
||||
def test_no_responses(self):
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "0_responses")
|
||||
page.visit()
|
||||
self.assertEqual(page.get_response_total_text(), "0 responses")
|
||||
self.assertFalse(page.has_add_response_button())
|
||||
self.assertEqual(page.get_num_displayed_responses(), 0)
|
||||
self.assertEqual(page.get_shown_responses_text(), None)
|
||||
self.assertIsNone(page.get_load_responses_button_text())
|
||||
|
||||
def test_few_responses(self):
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "5_responses")
|
||||
page.visit()
|
||||
self.assertEqual(page.get_response_total_text(), "5 responses")
|
||||
self.assertEqual(page.get_num_displayed_responses(), 5)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing all responses")
|
||||
self.assertIsNone(page.get_load_responses_button_text())
|
||||
|
||||
def test_two_response_pages(self):
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "50_responses")
|
||||
page.visit()
|
||||
self.assertEqual(page.get_response_total_text(), "50 responses")
|
||||
self.assertEqual(page.get_num_displayed_responses(), 25)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing first 25 responses")
|
||||
self.assertEqual(page.get_load_responses_button_text(), "Load all responses")
|
||||
|
||||
page.load_more_responses()
|
||||
self.assertEqual(page.get_num_displayed_responses(), 50)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing all responses")
|
||||
self.assertEqual(page.get_load_responses_button_text(), None)
|
||||
|
||||
def test_three_response_pages(self):
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "150_responses")
|
||||
page.visit()
|
||||
self.assertEqual(page.get_response_total_text(), "150 responses")
|
||||
self.assertEqual(page.get_num_displayed_responses(), 25)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing first 25 responses")
|
||||
self.assertEqual(page.get_load_responses_button_text(), "Load next 100 responses")
|
||||
|
||||
page.load_more_responses()
|
||||
self.assertEqual(page.get_num_displayed_responses(), 125)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing first 125 responses")
|
||||
self.assertEqual(page.get_load_responses_button_text(), "Load all responses")
|
||||
|
||||
page.load_more_responses()
|
||||
self.assertEqual(page.get_num_displayed_responses(), 150)
|
||||
self.assertEqual(page.get_shown_responses_text(), "Showing all responses")
|
||||
self.assertEqual(page.get_load_responses_button_text(), None)
|
||||
|
||||
def test_add_response_button(self):
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "5_responses")
|
||||
page.visit()
|
||||
self.assertTrue(page.has_add_response_button())
|
||||
page.click_add_response_button()
|
||||
|
||||
def test_add_response_button_closed_thread(self):
|
||||
page = DiscussionSingleThreadPage(self.browser, self.course_id, "5_responses_closed")
|
||||
page.visit()
|
||||
self.assertFalse(page.has_add_response_button())
|
||||
@@ -41,6 +41,11 @@ BOK_CHOY_STUBS = {
|
||||
:port => 8041,
|
||||
:log => File.join(BOK_CHOY_LOG_DIR, "bok_choy_ora.log"),
|
||||
:config => ''
|
||||
},
|
||||
|
||||
:comments => {
|
||||
:port => 4567,
|
||||
:log => File.join(BOK_CHOY_LOG_DIR, "bok_choy_comments.log")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user