From 02a233b7e2758f5bd2969012586ddd295db4cfb7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 6 Feb 2014 10:29:53 -0500 Subject: [PATCH 1/2] Rename URL_PATH to url_path in acceptance code It's not a constant, and some subclasses will want to make it a function with @property. --- common/test/acceptance/pages/lms/course_about.py | 2 +- common/test/acceptance/pages/lms/course_info.py | 2 +- common/test/acceptance/pages/lms/course_page.py | 4 ++-- common/test/acceptance/pages/lms/progress.py | 2 +- common/test/acceptance/pages/studio/asset_index.py | 2 +- common/test/acceptance/pages/studio/checklists.py | 2 +- common/test/acceptance/pages/studio/course_import.py | 2 +- common/test/acceptance/pages/studio/course_info.py | 2 +- common/test/acceptance/pages/studio/course_page.py | 4 ++-- common/test/acceptance/pages/studio/edit_tabs.py | 2 +- common/test/acceptance/pages/studio/export.py | 2 +- common/test/acceptance/pages/studio/manage_users.py | 2 +- common/test/acceptance/pages/studio/overview.py | 2 +- common/test/acceptance/pages/studio/settings.py | 2 +- common/test/acceptance/pages/studio/settings_advanced.py | 2 +- common/test/acceptance/pages/studio/settings_graders.py | 2 +- common/test/acceptance/pages/studio/textbooks.py | 2 +- 17 files changed, 19 insertions(+), 19 deletions(-) diff --git a/common/test/acceptance/pages/lms/course_about.py b/common/test/acceptance/pages/lms/course_about.py index b7dacd4ba6..004ed98f65 100644 --- a/common/test/acceptance/pages/lms/course_about.py +++ b/common/test/acceptance/pages/lms/course_about.py @@ -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') diff --git a/common/test/acceptance/pages/lms/course_info.py b/common/test/acceptance/pages/lms/course_info.py index cdccf7343b..af4b0893c9 100644 --- a/common/test/acceptance/pages/lms/course_info.py +++ b/common/test/acceptance/pages/lms/course_info.py @@ -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') diff --git a/common/test/acceptance/pages/lms/course_page.py b/common/test/acceptance/pages/lms/course_page.py index 40c5fd031c..1453f5cd3c 100644 --- a/common/test/acceptance/pages/lms/course_page.py +++ b/common/test/acceptance/pages/lms/course_page.py @@ -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 diff --git a/common/test/acceptance/pages/lms/progress.py b/common/test/acceptance/pages/lms/progress.py index 739beaa705..2903dd8d46 100644 --- a/common/test/acceptance/pages/lms/progress.py +++ b/common/test/acceptance/pages/lms/progress.py @@ -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') diff --git a/common/test/acceptance/pages/studio/asset_index.py b/common/test/acceptance/pages/studio/asset_index.py index 805852f201..b77b71605c 100644 --- a/common/test/acceptance/pages/studio/asset_index.py +++ b/common/test/acceptance/pages/studio/asset_index.py @@ -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') diff --git a/common/test/acceptance/pages/studio/checklists.py b/common/test/acceptance/pages/studio/checklists.py index 3a541cc724..35579ae607 100644 --- a/common/test/acceptance/pages/studio/checklists.py +++ b/common/test/acceptance/pages/studio/checklists.py @@ -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') diff --git a/common/test/acceptance/pages/studio/course_import.py b/common/test/acceptance/pages/studio/course_import.py index c4fc4c6c04..c731409b82 100644 --- a/common/test/acceptance/pages/studio/course_import.py +++ b/common/test/acceptance/pages/studio/course_import.py @@ -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') diff --git a/common/test/acceptance/pages/studio/course_info.py b/common/test/acceptance/pages/studio/course_info.py index 8fadb3f748..2f18ffc42b 100644 --- a/common/test/acceptance/pages/studio/course_info.py +++ b/common/test/acceptance/pages/studio/course_info.py @@ -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') diff --git a/common/test/acceptance/pages/studio/course_page.py b/common/test/acceptance/pages/studio/course_page.py index 4f80984dc0..d2f8ba1bb3 100644 --- a/common/test/acceptance/pages/studio/course_page.py +++ b/common/test/acceptance/pages/studio/course_page.py @@ -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'] ]) diff --git a/common/test/acceptance/pages/studio/edit_tabs.py b/common/test/acceptance/pages/studio/edit_tabs.py index 516e7d772a..e1133119be 100644 --- a/common/test/acceptance/pages/studio/edit_tabs.py +++ b/common/test/acceptance/pages/studio/edit_tabs.py @@ -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') diff --git a/common/test/acceptance/pages/studio/export.py b/common/test/acceptance/pages/studio/export.py index c4e5644068..af77096312 100644 --- a/common/test/acceptance/pages/studio/export.py +++ b/common/test/acceptance/pages/studio/export.py @@ -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') diff --git a/common/test/acceptance/pages/studio/manage_users.py b/common/test/acceptance/pages/studio/manage_users.py index 3521a18fcc..bd9c4647bd 100644 --- a/common/test/acceptance/pages/studio/manage_users.py +++ b/common/test/acceptance/pages/studio/manage_users.py @@ -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') diff --git a/common/test/acceptance/pages/studio/overview.py b/common/test/acceptance/pages/studio/overview.py index 6e67e7b81f..3f6d5602e2 100644 --- a/common/test/acceptance/pages/studio/overview.py +++ b/common/test/acceptance/pages/studio/overview.py @@ -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') diff --git a/common/test/acceptance/pages/studio/settings.py b/common/test/acceptance/pages/studio/settings.py index 7134660d7b..c297ff56ee 100644 --- a/common/test/acceptance/pages/studio/settings.py +++ b/common/test/acceptance/pages/studio/settings.py @@ -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') diff --git a/common/test/acceptance/pages/studio/settings_advanced.py b/common/test/acceptance/pages/studio/settings_advanced.py index 4f6b8893a1..4dd8c546a5 100644 --- a/common/test/acceptance/pages/studio/settings_advanced.py +++ b/common/test/acceptance/pages/studio/settings_advanced.py @@ -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') diff --git a/common/test/acceptance/pages/studio/settings_graders.py b/common/test/acceptance/pages/studio/settings_graders.py index af6194fcc5..92f7eeb13a 100644 --- a/common/test/acceptance/pages/studio/settings_graders.py +++ b/common/test/acceptance/pages/studio/settings_graders.py @@ -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') diff --git a/common/test/acceptance/pages/studio/textbooks.py b/common/test/acceptance/pages/studio/textbooks.py index 460a83df13..c863ef8585 100644 --- a/common/test/acceptance/pages/studio/textbooks.py +++ b/common/test/acceptance/pages/studio/textbooks.py @@ -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') From 8e3a77fa1ef5e51b3a6e2755b709aa9279f014b8 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 6 Feb 2014 11:02:04 -0500 Subject: [PATCH 2/2] Add acceptance tests for forum response pagination --- common/djangoapps/terrain/stubs/comments.py | 79 ++++++++++++++++++ common/djangoapps/terrain/stubs/http.py | 7 ++ common/djangoapps/terrain/stubs/start.py | 4 +- .../pages/lms/discussion_single_thread.py | 70 ++++++++++++++++ .../test/acceptance/tests/test_discussion.py | 81 +++++++++++++++++++ rakelib/bok_choy.rake | 5 ++ 6 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 common/djangoapps/terrain/stubs/comments.py create mode 100644 common/test/acceptance/pages/lms/discussion_single_thread.py create mode 100644 common/test/acceptance/tests/test_discussion.py diff --git a/common/djangoapps/terrain/stubs/comments.py b/common/djangoapps/terrain/stubs/comments.py new file mode 100644 index 0000000000..0d6a538dbe --- /dev/null +++ b/common/djangoapps/terrain/stubs/comments.py @@ -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\\d+)$": self.do_user, + "/api/v1/threads$": self.do_threads, + "/api/v1/threads/(?P\\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\\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 diff --git a/common/djangoapps/terrain/stubs/http.py b/common/djangoapps/terrain/stubs/http.py index 6450e7fc93..7a6f71cadd 100644 --- a/common/djangoapps/terrain/stubs/http.py +++ b/common/djangoapps/terrain/stubs/http.py @@ -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. diff --git a/common/djangoapps/terrain/stubs/start.py b/common/djangoapps/terrain/stubs/start.py index 2efdb2aab7..e58ecf243b 100644 --- a/common/djangoapps/terrain/stubs/start.py +++ b/common/djangoapps/terrain/stubs/start.py @@ -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 diff --git a/common/test/acceptance/pages/lms/discussion_single_thread.py b/common/test/acceptance/pages/lms/discussion_single_thread.py new file mode 100644 index 0000000000..325fa52e30 --- /dev/null +++ b/common/test/acceptance/pages/lms/discussion_single_thread.py @@ -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" + )) diff --git a/common/test/acceptance/tests/test_discussion.py b/common/test/acceptance/tests/test_discussion.py new file mode 100644 index 0000000000..2e282df588 --- /dev/null +++ b/common/test/acceptance/tests/test_discussion.py @@ -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()) diff --git a/rakelib/bok_choy.rake b/rakelib/bok_choy.rake index dda5b9eaf9..35bf7335ff 100644 --- a/rakelib/bok_choy.rake +++ b/rakelib/bok_choy.rake @@ -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") } }