From 16847d85a91e7c706b8a7f407a9dcb945e1758f4 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 4 Apr 2014 15:46:30 -0400 Subject: [PATCH] Add bok-choy tests for inline response pagination --- common/djangoapps/terrain/stubs/comments.py | 12 + .../test/acceptance/pages/lms/courseware.py | 16 ++ ...cussion_single_thread.py => discussion.py} | 131 +++++++--- .../test/acceptance/tests/test_discussion.py | 239 ++++++++++++------ 4 files changed, 286 insertions(+), 112 deletions(-) create mode 100644 common/test/acceptance/pages/lms/courseware.py rename common/test/acceptance/pages/lms/{discussion_single_thread.py => discussion.py} (58%) diff --git a/common/djangoapps/terrain/stubs/comments.py b/common/djangoapps/terrain/stubs/comments.py index 28f3729b6e..eaca957935 100644 --- a/common/djangoapps/terrain/stubs/comments.py +++ b/common/djangoapps/terrain/stubs/comments.py @@ -14,6 +14,7 @@ class StubCommentsServiceHandler(StubHttpRequestHandler): "/api/v1/threads$": self.do_threads, "/api/v1/threads/(?P\\w+)$": self.do_thread, "/api/v1/comments/(?P\\w+)$": self.do_comment, + "/api/v1/(?P\\w+)/threads$": self.do_commentable, } path = urlparse.urlparse(self.path).path for pattern in pattern_handlers: @@ -63,6 +64,17 @@ class StubCommentsServiceHandler(StubHttpRequestHandler): comment = self.server.config['comments'][comment_id] self.send_json_response(comment) + def do_commentable(self, commentable_id): + self.send_json_response({ + "collection": [ + thread + for thread in self.server.config.get('threads', {}).values() + if thread.get('commentable_id') == commentable_id + ], + "page": 1, + "num_pages": 1, + }) + class StubCommentsService(StubHttpService): HANDLER_CLASS = StubCommentsServiceHandler diff --git a/common/test/acceptance/pages/lms/courseware.py b/common/test/acceptance/pages/lms/courseware.py new file mode 100644 index 0000000000..44e5b8d594 --- /dev/null +++ b/common/test/acceptance/pages/lms/courseware.py @@ -0,0 +1,16 @@ +""" +Courseware page. +""" + +from .course_page import CoursePage + + +class CoursewarePage(CoursePage): + """ + Course info. + """ + + url_path = "courseware" + + def is_browser_on_page(self): + return self.q(css='body.courseware').present diff --git a/common/test/acceptance/pages/lms/discussion_single_thread.py b/common/test/acceptance/pages/lms/discussion.py similarity index 58% rename from common/test/acceptance/pages/lms/discussion_single_thread.py rename to common/test/acceptance/pages/lms/discussion.py index 7ca23de40c..5652ccfcca 100644 --- a/common/test/acceptance/pages/lms/discussion_single_thread.py +++ b/common/test/acceptance/pages/lms/discussion.py @@ -1,39 +1,45 @@ -from bok_choy.page_object import unguarded +from bok_choy.page_object import PageObject from bok_choy.promise import EmptyPromise 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 +class DiscussionThreadPage(PageObject): + url = None + + def __init__(self, browser, thread_selector): + super(DiscussionThreadPage, self).__init__(browser) + self.thread_selector = thread_selector + + def _find_within(self, selector): + """ + Returns a query corresponding to the given CSS selector within the scope + of this thread page + """ + return self.q(css=self.thread_selector + " " + selector) def is_browser_on_page(self): - return self.q( - css="body.discussion .discussion-article[data-id='{thread_id}']".format(thread_id=self.thread_id) - ).present - - @property - @unguarded - def url_path(self): - return "discussion/forum/dummy/threads/" + self.thread_id + return self.q(css=self.thread_selector).present 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.q(css=selector).text + text_list = self._find_within(selector).text return text_list[0] if text_list else None + def _is_element_visible(self, selector): + query = self._find_within(selector) + return query.present and query.visible + 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 len(self.q(css=".discussion-response").results) + return len(self._find_within(".discussion-response")) def get_shown_responses_text(self): """Returns the shown response count text, or None if not present""" @@ -45,7 +51,7 @@ class DiscussionSingleThreadPage(CoursePage): def load_more_responses(self): """Clicks the load more responses button and waits for responses to load""" - self.q(css=".load-response-button").first.click() + self._find_within(".load-response-button").click() def _is_ajax_finished(): return self.browser.execute_script("return jQuery.active") == 0 @@ -64,25 +70,19 @@ class DiscussionSingleThreadPage(CoursePage): Clicks the add response button and ensures that the response text field receives focus """ - self.q(css=".add-response-btn").first.click() + self._find_within(".add-response-btn").first.click() EmptyPromise( - lambda: self.q(css="#wmd-input-reply-body-{thread_id}:focus".format(thread_id=self.thread_id)), + lambda: self._find_within(".discussion-reply-new textarea:focus").present, "Response field received focus" ).fulfill() - def _is_element_visible(self, selector): - return ( - self.q(css=selector).present and - self.q(css=selector).visible - ) - def is_response_editor_visible(self, response_id): """Returns true if the response editor is present, false otherwise""" return self._is_element_visible(".response_{} .edit-post-body".format(response_id)) def start_response_edit(self, response_id): """Click the edit button for the response, loading the editing view""" - self.q(css=".response_{} .discussion-response .action-edit".format(response_id)).first.click() + self._find_within(".response_{} .discussion-response .action-edit".format(response_id)).first.click() EmptyPromise( lambda: self.is_response_editor_visible(response_id), "Response edit started" @@ -105,7 +105,7 @@ class DiscussionSingleThreadPage(CoursePage): def delete_comment(self, comment_id): with self.handle_alert(): - self.q(css="#comment_{} div.action-delete".format(comment_id)).first.click() + self._find_within("#comment_{} div.action-delete".format(comment_id)).first.click() EmptyPromise( lambda: not self.is_comment_visible(comment_id), "Deleted comment was removed" @@ -120,12 +120,12 @@ class DiscussionSingleThreadPage(CoursePage): return self._is_element_visible(".edit-comment-body[data-id='{}']".format(comment_id)) def _get_comment_editor_value(self, comment_id): - return self.q(css="#wmd-input-edit-comment-body-{}".format(comment_id)).text[0] + return self._find_within("#wmd-input-edit-comment-body-{}".format(comment_id)).text[0] def start_comment_edit(self, comment_id): """Click the edit button for the comment, loading the editing view""" old_body = self.get_comment_body(comment_id) - self.q(css="#comment_{} .action-edit".format(comment_id)).first.click() + self._find_within("#comment_{} .action-edit".format(comment_id)).first.click() EmptyPromise( lambda: ( self.is_comment_editor_visible(comment_id) and @@ -137,11 +137,11 @@ class DiscussionSingleThreadPage(CoursePage): def set_comment_editor_value(self, comment_id, new_body): """Replace the contents of the comment editor""" - self.q(css="#comment_{} .wmd-input".format(comment_id)).fill(new_body) + self._find_within("#comment_{} .wmd-input".format(comment_id)).fill(new_body) def submit_comment_edit(self, comment_id, new_comment_body): """Click the submit button on the comment editor""" - self.q(css="#comment_{} .post-update".format(comment_id)).first.click() + self._find_within("#comment_{} .post-update".format(comment_id)).first.click() EmptyPromise( lambda: ( not self.is_comment_editor_visible(comment_id) and @@ -153,7 +153,7 @@ class DiscussionSingleThreadPage(CoursePage): def cancel_comment_edit(self, comment_id, original_body): """Click the cancel button on the comment editor""" - self.q(css="#comment_{} .post-cancel".format(comment_id)).first.click() + self._find_within("#comment_{} .post-cancel".format(comment_id)).first.click() EmptyPromise( lambda: ( not self.is_comment_editor_visible(comment_id) and @@ -162,3 +162,72 @@ class DiscussionSingleThreadPage(CoursePage): ), "Comment edit was canceled" ).fulfill() + + +class DiscussionTabSingleThreadPage(CoursePage): + def __init__(self, browser, course_id, thread_id): + super(DiscussionTabSingleThreadPage, self).__init__(browser, course_id) + self.thread_page = DiscussionThreadPage( + browser, + "body.discussion .discussion-article[data-id='{thread_id}']".format(thread_id=thread_id) + ) + self.url_path = "discussion/forum/dummy/threads/" + thread_id + + def is_browser_on_page(self): + return self.thread_page.is_browser_on_page() + + def __getattr__(self, name): + return getattr(self.thread_page, name) + + +class InlineDiscussionPage(PageObject): + url = None + + def __init__(self, browser, discussion_id): + super(InlineDiscussionPage, self).__init__(browser) + self._discussion_selector = ( + "body.courseware .discussion-module[data-discussion-id='{discussion_id}'] ".format( + discussion_id=discussion_id + ) + ) + + def _find_within(self, selector): + """ + Returns a query corresponding to the given CSS selector within the scope + of this discussion page + """ + return self.q(css=self._discussion_selector + " " + selector) + + def is_browser_on_page(self): + return self.q(css=self._discussion_selector).present + + def is_discussion_expanded(self): + return self._find_within(".discussion").present + + def expand_discussion(self): + """Click the link to expand the discussion""" + self._find_within(".discussion-show").first.click() + EmptyPromise( + self.is_discussion_expanded, + "Discussion expanded" + ).fulfill() + + def get_num_displayed_threads(self): + return len(self._find_within(".discussion-thread")) + + +class InlineDiscussionThreadPage(DiscussionThreadPage): + def __init__(self, browser, thread_id): + super(InlineDiscussionThreadPage, self).__init__( + browser, + "body.courseware .discussion-module #thread_{thread_id}".format(thread_id=thread_id) + ) + + def expand(self): + """Clicks the link to expand the thread""" + self._find_within(".expand-post").first.click() + EmptyPromise( + lambda: bool(self.get_response_total_text()), + "Thread expanded" + ).fulfill() + diff --git a/common/test/acceptance/tests/test_discussion.py b/common/test/acceptance/tests/test_discussion.py index f52029dfd2..cad4779bb2 100644 --- a/common/test/acceptance/tests/test_discussion.py +++ b/common/test/acceptance/tests/test_discussion.py @@ -2,96 +2,132 @@ Tests for discussion pages """ +from uuid import uuid4 + from .helpers import UniqueCourseTest from ..pages.studio.auto_auth import AutoAuthPage -from ..pages.lms.discussion_single_thread import DiscussionSingleThreadPage -from ..fixtures.course import CourseFixture +from ..pages.lms.courseware import CoursewarePage +from ..pages.lms.discussion import ( + DiscussionTabSingleThreadPage, + InlineDiscussionPage, + InlineDiscussionThreadPage +) +from ..fixtures.course import CourseFixture, XBlockFixtureDesc from ..fixtures.discussion import SingleThreadViewFixture, Thread, Response, Comment -class DiscussionSingleThreadTest(UniqueCourseTest): +class DiscussionResponsePaginationTestMixin(object): + """ + A mixin containing tests for response pagination for use by both inline + discussion and the discussion tab + """ + + def setup_thread(self, num_responses, **thread_kwargs): + """ + Create a test thread with the given number of responses, passing all + keyword arguments through to the Thread fixture, then invoke + setup_thread_page. + """ + thread_id = "test_thread_{}".format(uuid4().hex) + thread_fixture = SingleThreadViewFixture( + Thread(id=thread_id, commentable_id=self.discussion_id, **thread_kwargs) + ) + for i in range(num_responses): + thread_fixture.addResponse(Response(id=str(i), body=str(i))) + thread_fixture.push() + self.setup_thread_page(thread_id) + + def assert_response_display_correct(self, response_total, displayed_responses): + """ + Assert that various aspects of the display of responses are all correct: + * Text indicating total number of responses + * Presence of "Add a response" button + * Number of responses actually displayed + * Presence and text of indicator of how many responses are shown + * Presence and text of button to load more responses + """ + self.assertEqual( + self.thread_page.get_response_total_text(), + str(response_total) + " responses" + ) + self.assertEqual(self.thread_page.has_add_response_button(), response_total != 0) + self.assertEqual(self.thread_page.get_num_displayed_responses(), displayed_responses) + self.assertEqual( + self.thread_page.get_shown_responses_text(), + ( + None if response_total == 0 else + "Showing all responses" if response_total == displayed_responses else + "Showing first {} responses".format(displayed_responses) + ) + ) + self.assertEqual( + self.thread_page.get_load_responses_button_text(), + ( + None if response_total == displayed_responses else + "Load all responses" if response_total - displayed_responses < 100 else + "Load next 100 responses" + ) + ) + + def test_pagination_no_responses(self): + self.setup_thread(0) + self.assert_response_display_correct(0, 0) + + def test_pagination_few_responses(self): + self.setup_thread(5) + self.assert_response_display_correct(5, 5) + + def test_pagination_two_response_pages(self): + self.setup_thread(50) + self.assert_response_display_correct(50, 25) + + self.thread_page.load_more_responses() + self.assert_response_display_correct(50, 50) + + def test_pagination_exactly_two_response_pages(self): + self.setup_thread(125) + self.assert_response_display_correct(125, 25) + + self.thread_page.load_more_responses() + self.assert_response_display_correct(125, 125) + + def test_pagination_three_response_pages(self): + self.setup_thread(150) + self.assert_response_display_correct(150, 25) + + self.thread_page.load_more_responses() + self.assert_response_display_correct(150, 125) + + self.thread_page.load_more_responses() + self.assert_response_display_correct(150, 150) + + def test_add_response_button(self): + self.setup_thread(5) + self.assertTrue(self.thread_page.has_add_response_button()) + self.thread_page.click_add_response_button() + + def test_add_response_button_closed_thread(self): + self.setup_thread(5, closed=True) + self.assertFalse(self.thread_page.has_add_response_button()) + + +class DiscussionTabSingleThreadTest(UniqueCourseTest, DiscussionResponsePaginationTestMixin): """ Tests for the discussion page displaying a single thread """ def setUp(self): - super(DiscussionSingleThreadTest, self).setUp() + super(DiscussionTabSingleThreadTest, self).setUp() + self.discussion_id = "test_discussion_{}".format(uuid4().hex) # Create a course to register for CourseFixture(**self.course_info).install() - self.user_id = AutoAuthPage(self.browser, course_id=self.course_id).visit().get_user_id() + AutoAuthPage(self.browser, course_id=self.course_id).visit() - def setup_thread(self, thread, num_responses): - view = SingleThreadViewFixture(thread=thread) - for i in range(num_responses): - view.addResponse(Response(id=str(i), body=str(i))) - view.push() - - def test_no_responses(self): - self.setup_thread(Thread(id="0_responses"), 0) - 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): - self.setup_thread(Thread(id="5_responses"), 5) - 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): - self.setup_thread(Thread(id="50_responses"), 50) - 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): - self.setup_thread(Thread(id="150_responses"), 150) - 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): - self.setup_thread(Thread(id="5_responses"), 5) - 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): - self.setup_thread(Thread(id="5_responses_closed", closed=True), 5) - page = DiscussionSingleThreadPage(self.browser, self.course_id, "5_responses_closed") - page.visit() - self.assertFalse(page.has_add_response_button()) + def setup_thread_page(self, thread_id): + self.thread_page = DiscussionTabSingleThreadPage(self.browser, self.course_id, thread_id) # pylint:disable=W0201 + self.thread_page.visit() class DiscussionCommentDeletionTest(UniqueCourseTest): @@ -119,7 +155,7 @@ class DiscussionCommentDeletionTest(UniqueCourseTest): def test_comment_deletion_as_student(self): self.setup_user() self.setup_view() - page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_deletion_test_thread") + page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_deletion_test_thread") page.visit() self.assertTrue(page.is_comment_deletable("comment_self_author")) self.assertTrue(page.is_comment_visible("comment_other_author")) @@ -129,7 +165,7 @@ class DiscussionCommentDeletionTest(UniqueCourseTest): def test_comment_deletion_as_moderator(self): self.setup_user(roles=['Moderator']) self.setup_view() - page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_deletion_test_thread") + page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_deletion_test_thread") page.visit() self.assertTrue(page.is_comment_deletable("comment_self_author")) self.assertTrue(page.is_comment_deletable("comment_other_author")) @@ -168,7 +204,7 @@ class DiscussionCommentEditTest(UniqueCourseTest): def test_edit_comment_as_student(self): self.setup_user() self.setup_view() - page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread") + page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread") page.visit() self.assertTrue(page.is_comment_editable("comment_self_author")) self.assertTrue(page.is_comment_visible("comment_other_author")) @@ -178,7 +214,7 @@ class DiscussionCommentEditTest(UniqueCourseTest): def test_edit_comment_as_moderator(self): self.setup_user(roles=["Moderator"]) self.setup_view() - page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread") + page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread") page.visit() self.assertTrue(page.is_comment_editable("comment_self_author")) self.assertTrue(page.is_comment_editable("comment_other_author")) @@ -188,7 +224,7 @@ class DiscussionCommentEditTest(UniqueCourseTest): def test_cancel_comment_edit(self): self.setup_user() self.setup_view() - page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread") + page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread") page.visit() self.assertTrue(page.is_comment_editable("comment_self_author")) original_body = page.get_comment_body("comment_self_author") @@ -200,7 +236,7 @@ class DiscussionCommentEditTest(UniqueCourseTest): """Only one editor should be visible at a time within a single response""" self.setup_user(roles=["Moderator"]) self.setup_view() - page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread") + page = DiscussionTabSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread") page.visit() self.assertTrue(page.is_comment_editable("comment_self_author")) self.assertTrue(page.is_comment_editable("comment_other_author")) @@ -224,3 +260,44 @@ class DiscussionCommentEditTest(UniqueCourseTest): page.cancel_comment_edit("comment_self_author", original_body) self.assertFalse(page.is_comment_editor_visible("comment_self_author")) self.assertTrue(page.is_add_comment_visible("response1")) + + +class InlineDiscussionTest(UniqueCourseTest, DiscussionResponsePaginationTestMixin): + """ + Tests for inline discussions + """ + + def setUp(self): + super(InlineDiscussionTest, self).setUp() + self.discussion_id = "test_discussion_{}".format(uuid4().hex) + CourseFixture(**self.course_info).add_children( + XBlockFixtureDesc("chapter", "Test Section").add_children( + XBlockFixtureDesc("sequential", "Test Subsection").add_children( + XBlockFixtureDesc("vertical", "Test Unit").add_children( + XBlockFixtureDesc( + "discussion", + "Test Discussion", + metadata={"discussion_id": self.discussion_id} + ) + ) + ) + ) + ).install() + + AutoAuthPage(self.browser, course_id=self.course_id).visit() + + CoursewarePage(self.browser, self.course_id).visit() + self.discussion_page = InlineDiscussionPage(self.browser, self.discussion_id) + + def setup_thread_page(self, thread_id): + self.discussion_page.expand_discussion() + self.assertEqual(self.discussion_page.get_num_displayed_threads(), 1) + self.thread_page = InlineDiscussionThreadPage(self.browser, thread_id) # pylint:disable=W0201 + self.thread_page.expand() + + def test_initial_render(self): + self.assertFalse(self.discussion_page.is_discussion_expanded()) + + def test_expand_discussion_empty(self): + self.discussion_page.expand_discussion() + self.assertEqual(self.discussion_page.get_num_displayed_threads(), 0)