From 574faf5815ec57d40f56481e0d0867f6ff19b456 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Tue, 4 Jul 2023 11:10:06 +0200 Subject: [PATCH 01/61] Updated version --- apps/student/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/student/build.gradle b/apps/student/build.gradle index fa840d55f2..d681217629 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -50,8 +50,8 @@ android { applicationId "com.instructure.candroid" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 251 - versionName = '6.24.0' + versionCode = 252 + versionName = '6.25.0' vectorDrawables.useSupportLibrary = true multiDexEnabled = true From 101e78d9756ad0b1efea865f5fdc3a6cb71aab76 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Mon, 10 Jul 2023 10:10:57 +0200 Subject: [PATCH 02/61] [MBL-16524][Teacher] - Create KDoc for complex functions and classes (#2048) --- .../ui/interaction/ModuleInteractionTest.kt | 29 ++- .../teacher/ui/e2e/AnnouncementsE2ETest.kt | 9 + .../teacher/ui/e2e/InboxE2ETest.kt | 7 +- .../teacher/ui/e2e/PeopleE2ETest.kt | 7 +- .../instructure/teacher/ui/pages/AboutPage.kt | 24 +++ .../teacher/ui/pages/AddMessagePage.kt | 85 ++++++-- .../teacher/ui/pages/AllCoursesListPage.kt | 26 ++- .../teacher/ui/pages/AnnouncementsListPage.kt | 102 +++++++++- .../teacher/ui/pages/AssigneeListPage.kt | 62 +++++- .../teacher/ui/pages/AssignmentDetailsPage.kt | 139 +++++++++++++ .../ui/pages/AssignmentDueDatesPage.kt | 49 +++++ .../teacher/ui/pages/AssignmentListPage.kt | 61 +++++- .../ui/pages/AssignmentSubmissionListPage.kt | 183 +++++++++++++++-- .../teacher/ui/pages/CalendarEventPage.kt | 21 ++ .../teacher/ui/pages/ChooseRecipientsPage.kt | 38 +++- .../teacher/ui/pages/CommentLibraryPage.kt | 47 ++++- .../teacher/ui/pages/CourseBrowserPage.kt | 79 +++++++- .../teacher/ui/pages/CourseSettingsPage.kt | 49 ++++- .../teacher/ui/pages/DashboardPage.kt | 138 ++++++++++++- .../ui/pages/DiscussionsDetailsPage.kt | 29 ++- .../teacher/ui/pages/DiscussionsListPage.kt | 84 ++++++++ .../teacher/ui/pages/EditAnnouncementPage.kt | 17 ++ .../ui/pages/EditAssignmentDetailsPage.kt | 151 +++++++++++++- .../teacher/ui/pages/EditDashboardPage.kt | 71 ++++++- .../ui/pages/EditDiscussionsDetailsPage.kt | 37 +++- .../teacher/ui/pages/EditPageDetailsPage.kt | 60 +++++- .../ui/pages/EditProfileSettingsPage.kt | 23 ++- .../teacher/ui/pages/EditQuizDetailsPage.kt | 167 +++++++++++++--- .../teacher/ui/pages/EditSyllabusPage.kt | 41 +++- .../teacher/ui/pages/FileListPage.kt | 86 +++++++- .../instructure/teacher/ui/pages/HelpPage.kt | 65 +++++- .../teacher/ui/pages/InboxMessagePage.kt | 36 +++- .../instructure/teacher/ui/pages/InboxPage.kt | 187 +++++++++++++++++- .../ui/pages/LeftSideNavigationDrawerPage.kt | 38 ++++ .../instructure/teacher/ui/pages/LegalPage.kt | 26 ++- .../teacher/ui/pages/LoginFindSchoolPage.kt | 16 ++ .../teacher/ui/pages/LoginLandingPage.kt | 41 ++++ .../teacher/ui/pages/LoginSignInPage.kt | 45 +++++ .../teacher/ui/pages/ModulesPage.kt | 49 +++++ .../teacher/ui/pages/NavDrawerPage.kt | 12 ++ .../teacher/ui/pages/NotATeacherPage.kt | 10 +- .../teacher/ui/pages/PageListPage.kt | 95 +++++++-- .../teacher/ui/pages/PeopleListPage.kt | 78 ++++++-- .../teacher/ui/pages/PersonContextPage.kt | 25 +++ .../teacher/ui/pages/PostSettingsPage.kt | 39 +++- .../teacher/ui/pages/ProfileSettingsPage.kt | 17 ++ .../teacher/ui/pages/QuizDetailsPage.kt | 94 ++++++++- .../teacher/ui/pages/QuizListPage.kt | 68 ++++++- .../ui/pages/QuizSubmissionListPage.kt | 98 ++++++++- .../ui/pages/RemoteConfigSettingsPage.kt | 34 +++- .../teacher/ui/pages/SettingsPage.kt | 56 +++++- .../ui/pages/SpeedGraderCommentsPage.kt | 104 +++++++++- .../teacher/ui/pages/SpeedGraderFilesPage.kt | 34 +++- .../teacher/ui/pages/SpeedGraderGradePage.kt | 103 +++++++++- .../teacher/ui/pages/SpeedGraderPage.kt | 79 ++++++++ .../ui/pages/SpeedGraderQuizSubmissionPage.kt | 35 +++- .../teacher/ui/pages/StudentContextPage.kt | 34 ++++ .../teacher/ui/pages/SyllabusPage.kt | 46 +++++ .../instructure/teacher/ui/pages/TodoPage.kt | 35 +++- .../teacher/ui/pages/WebViewLoginPage.kt | 59 ++++-- 60 files changed, 3333 insertions(+), 246 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt index 6d3bef69c2..3eabdaf9f9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt @@ -18,12 +18,34 @@ package com.instructure.student.ui.interaction import android.text.Html import androidx.test.espresso.Espresso import androidx.test.espresso.web.webdriver.Locator -import com.instructure.canvas.espresso.mockCanvas.* -import com.instructure.canvasapi2.models.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addDiscussionTopicToCourse +import com.instructure.canvas.espresso.mockCanvas.addFileToCourse +import com.instructure.canvas.espresso.mockCanvas.addItemToModule +import com.instructure.canvas.espresso.mockCanvas.addLTITool +import com.instructure.canvas.espresso.mockCanvas.addModuleToCourse +import com.instructure.canvas.espresso.mockCanvas.addPageToCourse +import com.instructure.canvas.espresso.mockCanvas.addQuestionToQuiz +import com.instructure.canvas.espresso.mockCanvas.addQuizToCourse +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.DiscussionTopicHeader +import com.instructure.canvasapi2.models.LockInfo +import com.instructure.canvasapi2.models.LockedModule +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.canvasapi2.models.Page +import com.instructure.canvasapi2.models.Quiz +import com.instructure.canvasapi2.models.QuizAnswer +import com.instructure.canvasapi2.models.Tab import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.panda_annotations.* +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.SecondaryFeatureCategory +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData import com.instructure.student.R import com.instructure.student.ui.pages.WebViewTextCheck import com.instructure.student.ui.utils.StudentTest @@ -272,6 +294,7 @@ class ModuleInteractionTest : StudentTest() { // Module can't be accessed unless all prerequisites have been fulfilled @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.MODULES, TestCategory.INTERACTION, false) + fun testModules_moduleLockedWithUnfulfilledPrerequisite() { // Basic mock setup val data = getToCourseModules(studentCount = 1, courseCount = 1) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt index f98cf3c940..6f8268f398 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt @@ -29,6 +29,11 @@ import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test +/** + * Announcements e2e test + * + * @constructor Create empty Announcements e2e test + */ @HiltAndroidTest class AnnouncementsE2ETest : TeacherTest() { @@ -36,6 +41,10 @@ class AnnouncementsE2ETest : TeacherTest() { override fun enableAndConfigureAccessibilityChecks() = Unit + /** + * Test announcements e2e + * + */ @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ANNOUNCEMENTS, TestCategory.E2E) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt index 84f202b50d..72bd37f362 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt @@ -81,11 +81,8 @@ class InboxE2ETest : TeacherTest() { addNewMessage(course,data.studentsList) val subject = "Hello there" - Log.d(STEP_TAG,"Fill in the 'Subject' field with the value: $subject.") - addMessagePage.addSubject(subject) - - Log.d(STEP_TAG,"Add some message text and click on 'Send' (aka. 'Arrow') button.") - addMessagePage.addMessage("General Kenobi") + Log.d(STEP_TAG,"Fill in the 'Subject' field with the value: $subject. Add some message text and click on 'Send' (aka. 'Arrow') button.") + addMessagePage.composeMessageWithSubject(subject, "General Kenobi") addMessagePage.clickSendButton() Log.d(STEP_TAG,"Filter the Inbox by selecting 'Sent' category from the spinner on Inbox Page.") diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt index fc35cd2622..08d0a0f2d5 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt @@ -131,11 +131,8 @@ class PeopleE2ETest: TeacherTest() { studentContextPage.clickOnNewMessageButton() val subject = "Test Subject" - Log.d(STEP_TAG,"Fill in the 'Subject' field with the value: $subject.") - addMessagePage.addSubject(subject) - - Log.d(STEP_TAG,"Add some message text and click on 'Send' (aka. 'Arrow') button.") - addMessagePage.addMessage("This a test message from student context page.") + Log.d(STEP_TAG,"Fill in the 'Subject' field with the value: $subject. Add some message text and click on 'Send' (aka. 'Arrow') button.") + addMessagePage.composeMessageWithSubject(subject, "This a test message from student context page.") addMessagePage.clickSendButton() Log.d(STEP_TAG, "Navigate back to People List Page.") diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AboutPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AboutPage.kt index 8a0827370b..8bc5a8729d 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AboutPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AboutPage.kt @@ -26,25 +26,49 @@ import com.instructure.espresso.page.withText import com.instructure.espresso.scrollTo import com.instructure.teacher.R +/** + * About page + * + * @constructor Create empty About page + */ class AboutPage : BasePage(R.id.aboutPage) { private val domainLabel by OnViewWithText(R.string.domain) private val loginIdLabel by OnViewWithText(R.string.loginId) private val emailLabel by OnViewWithText(R.string.email) + /** + * Checks whether the domains is displayed or not. + * + * @param domain string. + */ fun domainIs(domain: String) { onView(withId(R.id.domain) + withText(domain)).assertDisplayed() } + /** + * Checks whether the login ID is displayed or not. + * + * @param loginId string of a user. + */ fun loginIdIs(loginId: String) { onView(withId(R.id.loginId) + withText(loginId)).assertDisplayed() } + /** + * Checks whether the email is displayed or not. + * + * @param email string of a user. + */ fun emailIs(email: String) { onView(withId(R.id.email) + withText(email)).assertDisplayed() } + /** + * Assert that the Instructure logo is displayed. + * + */ fun assertInstructureLogoDisplayed() { onView(withId(R.id.instructureLogo)).scrollTo().assertDisplayed() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AddMessagePage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AddMessagePage.kt index 4e9409fa35..e8622a4785 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AddMessagePage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AddMessagePage.kt @@ -20,12 +20,21 @@ import com.google.android.material.chip.Chip import com.instructure.canvas.espresso.typedViewCondition import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.User -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.espresso.* +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertHasChild +import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.waitForViewWithText +import com.instructure.espresso.replaceText +import com.instructure.espresso.scrollTo import com.instructure.teacher.R +/** + * Add message page + * + * @constructor Create empty Add message page + */ class AddMessagePage: BasePage() { private val subjectTextView by WaitForViewWithId(R.id.subjectView) @@ -42,56 +51,102 @@ class AddMessagePage: BasePage() { chipsInput.assertDisplayed() } + /** + * Add a (reply) message to an existing conversation. + * + * @param message text itself. + */ fun addReply(message: String) { messageEditText.replaceText(message) sendButton.click() } + /** + * Assert compose new message objects displayed + * + */ fun assertComposeNewMessageObjectsDisplayed() { coursesSpinner.assertDisplayed() editSubjectEditText.assertDisplayed() } + /** + * Click course spinner + * + */ fun clickCourseSpinner() { coursesSpinner.click() } + /** + * Select course from spinner + * + * @param course: Object that we would like to select based on it's name. + */ fun selectCourseFromSpinner(course: Course) { selectCourseFromSpinner(course.name) } + /** + * Select course from spinner + * + * @param courseName: That we would like to select + */ fun selectCourseFromSpinner(courseName: String) { waitForViewWithText(courseName).click() } + /** + * Click add contacts button + * + */ fun clickAddContacts() { addContactsButton.click() } - fun assertHasStudentRecipient(student: CanvasUserApiModel) { - chipGroup.assertHasChild(typedViewCondition { it.text.toString() == student.shortName }) - } + /** + * Assert has student recipient + * + * @param student: The student object parameter. + */ fun assertHasStudentRecipient(student: User) { chipGroup.assertHasChild(typedViewCondition { it.text.toString() == student.shortName }) } - fun addNewMessage() { - val subject = randomString() - val message = randomString() + /** + * Replace the message subject with the given parameter. + * + * @param subject: New subject parameter. + */ + private fun addSubject(subject: String) { editSubjectEditText.replaceText(subject) - messageEditText.replaceText(message) - sendButton.click() } - fun addSubject(subject: String) { - editSubjectEditText.replaceText(subject) - } - - fun addMessage(message: String) { + /** + * Replace the message body with the given parameter. + * + * @param message: New message body parameter. + */ + private fun addMessage(message: String) { messageEditText.scrollTo() messageEditText.replaceText(message) } + /** + * Compose a message with subject + * + * @param subject: Subject of the message. + * @param message: Message body. + */ + fun composeMessageWithSubject(subject: String, message: String) { + addSubject(subject) + addMessage(message) + } + + /** + * Click send button. + * + */ fun clickSendButton() { sendButton.click() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AllCoursesListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AllCoursesListPage.kt index 13ed64ae6c..904f4f1a1a 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AllCoursesListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AllCoursesListPage.kt @@ -26,6 +26,11 @@ import com.instructure.teacher.R import org.hamcrest.CoreMatchers import org.hamcrest.Matcher +/** + * All courses list page + * + * @constructor Create empty All courses list page + */ @Suppress("unused") class AllCoursesListPage : BasePage() { @@ -39,15 +44,29 @@ class AllCoursesListPage : BasePage() { private val coursesRecyclerView by WaitForViewWithId(R.id.recyclerView) + /** + * Assert that all the courses given in the parameter list is displayed in the corresponding recycler view. + * + * @param mCourses: The Course object list parameter. + */ fun assertHasCourses(mCourses: List) { coursesRecyclerView.check(RecyclerViewItemCountAssertion(mCourses.size)) for (course in mCourses) onView(withText(course.name)).assertDisplayed() } + /** + * Navigate back + * + */ fun navigateBack() { backButton.click() } + /** + * Assert that the course given in the parameter is displayed. + * + * @param course: The CourseApiModel object parameter. + */ fun assertDisplaysCourse(course: CourseApiModel) { val matcher = CoreMatchers.allOf( withText(course.name), @@ -57,8 +76,13 @@ class AllCoursesListPage : BasePage() { scrollAndAssertDisplayed(matcher) } + /** + * Scroll to and assert that the View matcher given in the parameter is displayed. + * + * @param matcher: The View matcher parameter. + */ private fun scrollAndAssertDisplayed(matcher: Matcher) { - onView(matcher).assertDisplayed() + onView(matcher).scrollTo().assertDisplayed() } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt index 11cd5ddad3..be005e6b5e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt @@ -43,6 +43,11 @@ import com.instructure.espresso.waitForCheck import com.instructure.teacher.R import com.instructure.teacher.ui.utils.TypeInRCETextEditor +/** + * Announcements list page + * + * @constructor Create empty Announcements list page + */ class AnnouncementsListPage : BasePage() { private val announcementListToolbar by OnViewWithId(R.id.discussionListToolbar) @@ -52,50 +57,107 @@ class AnnouncementsListPage : BasePage() { private val searchInput by WaitForViewWithId(androidx.appcompat.R.id.search_src_text) private val createNewDiscussion by OnViewWithId(R.id.createNewDiscussion) + /** + * Click on the discussion given in parameter. + * + * @param discussion: The DiscussionApiModel parameter. + */ fun clickDiscussion(discussion: DiscussionApiModel) { clickDiscussion(discussion.title) } + /** + * Click on the discussion with the given title in parameter. + * + * @param discussionTitle: The discussion title parameter string. + */ fun clickDiscussion(discussionTitle: String) { waitForViewWithText(discussionTitle).click() } + /** + * Assert that there is an announcement with the given discussion title. + * + * @param discussion: The DiscussionTopicHeader object parameter. + */ fun assertHasAnnouncement(discussion: DiscussionTopicHeader) { assertHasAnnouncement(discussion.title!!) } + /** + * Assert that there is an announcement with the given discussion title. + * + * @param discussion: The DiscussionApiModel object parameter. + */ fun assertHasAnnouncement(discussion: DiscussionApiModel) { assertHasAnnouncement(discussion.title) } + /** + * Assert that there is an announcement with the given name is displayed. + * + * @param announcementName: The announcement name string parameter. + */ fun assertHasAnnouncement(announcementName: String) { onView(withText(announcementName)).assertDisplayed() } + /** + * Assert FAB is displayed. + * + */ fun assertFAB() { announcementsFAB.assertDisplayed() } + /** + * Click on search button. + * + */ fun openSearch() { searchButton.click() } + /** + * Fill the search input field with the given query string. + * + * @param query: Query string parameter. + */ fun enterSearchQuery(query: String) { searchInput.perform(ViewActions.replaceText(query)) } + /** + * Assert that the announcements recyclerview count is equals to the given one. + * + * @param count: The expected count integer parameter. + */ fun assertAnnouncementCount(count: Int) { announcementsRecyclerView.waitForCheck(RecyclerViewItemCountAssertion(count)) } + /** + * Assert that the empty view is displayed. + * + */ fun assertEmpty() { onView(withId(R.id.emptyPandaView)).assertDisplayed() } + /** + * Refresh the page (swipeRefreshLayout). + * + */ fun refresh() { onView(withId(R.id.swipeRefreshLayout)).swipeDown() } + /** + * Create a new announcement with the given name and details parameters. + * + * @param announcementName: Name of the new announcement. + * @param announcementDetails: Details of the new announcement. + */ fun createAnnouncement(announcementName: String, announcementDetails: String) { clickOnCreateNewAnnouncementButton() onView(withId(R.id.announcementNameEditText)).replaceText(announcementName) @@ -103,39 +165,77 @@ class AnnouncementsListPage : BasePage() { onView(withId(R.id.menuSaveAnnouncement)).click() } + /** + * Click on create announcement then close the creation view. + * + */ fun clickOnCreateAnnouncementThenClose() { clickOnCreateNewAnnouncementButton() onViewWithContentDescription("Close").click() } - fun clickOnCreateNewAnnouncementButton() { + /** + * Click on create new announcement button. + * + */ + private fun clickOnCreateNewAnnouncementButton() { createNewDiscussion.click() } + /** + * Verify exit without saving dialog. + * + */ fun verifyExitWithoutSavingDialog() { onViewWithText(R.string.exitWithoutSavingMessage).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) } + /** + * Assert on new announcement page. + * + */ fun assertOnNewAnnouncementPage() { Espresso.onView(ViewMatchers.withText(R.string.newAnnouncement)).assertDisplayed() } + /** + * Accept exit without save dialog. + * + */ fun acceptExitWithoutSaveDialog() { onViewWithText(R.string.exitUnsaved).click() } + /** + * Click search button. + * + */ fun clickSearchButton() { onView(withId(R.id.search)).click() } + /** + * Type the given search text into the search input field. + * + * @param searchText: The search text query parameter. + */ fun typeSearchInput(searchText: String) { onView(withId(R.id.search_src_text)).replaceText(searchText.dropLast(1)) } + /** + * Click reset search text. + * + */ fun clickResetSearchText() { waitForView(withId(R.id.search_close_btn)).click() } + /** + * Assert search result count is equals to the expected. + * + * @param expectedCount: The expected search result count integer parameter. + */ fun assertSearchResultCount(expectedCount: Int) { Thread.sleep(1000) onView(withId(R.id.discussionRecyclerView) + withAncestor(R.id.swipeRefreshLayout)).check( diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssigneeListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssigneeListPage.kt index dffd488a24..51faf5e03d 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssigneeListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssigneeListPage.kt @@ -22,7 +22,6 @@ import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.contrib.RecyclerViewActions.actionOnHolderItem import androidx.test.espresso.contrib.RecyclerViewActions.scrollToHolder import androidx.test.espresso.matcher.BoundedMatcher -import androidx.test.espresso.matcher.ViewMatchers.hasSibling import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import com.instructure.canvas.espresso.scrollRecyclerView @@ -41,36 +40,81 @@ import org.hamcrest.Description import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf +/** + * A page representing the Assignee List screen in the application. + */ @Suppress("unused") class AssigneeListPage : BasePage(pageResId = R.id.assigneeListPage) { + /** + * The title text view of the Assignee List screen. + */ private val titleTextView by OnViewWithText(R.string.page_title_add_assignees) + + /** + * The close button of the Assignee List screen. + */ private val closeButton by OnViewWithContentDescription(R.string.close) + + /** + * The save button of the Assignee List screen. + */ private val saveButton by OnViewWithId(R.id.menuSave) + + /** + * The recycler view of the Assignee List screen. + */ private val recyclerView by WaitForViewWithId(R.id.recyclerView) + /** + * Asserts that the Assignee List screen displays the assignee options. + * + * @param sectionNames The list of section names to verify. + * @param groupNames The list of group names to verify. + * @param studentNames The list of student names to verify. + */ fun assertDisplaysAssigneeOptions( - sectionNames: List = emptyList(), - groupNames: List = emptyList(), - studentNames: List = emptyList()) { + sectionNames: List = emptyList(), + groupNames: List = emptyList(), + studentNames: List = emptyList() + ) { for (assigneeName in (sectionNames + groupNames + studentNames)) { - var targetView = allOf(withText(assigneeName), withId(R.id.assigneeTitleView)) + val targetView = allOf(withText(assigneeName), withId(R.id.assigneeTitleView)) scrollRecyclerView(R.id.recyclerView, targetView) onView(targetView).assertDisplayed() } } + /** + * Asserts that the specified assignees are selected. + * + * @param assigneeNames The list of assignee names to verify. + */ fun assertAssigneesSelected(assigneeNames: List) { val selectedTextView = onViewWithId(R.id.selectedAssigneesTextView) - for (name in assigneeNames) selectedTextView.assertContainsText(name) + for (name in assigneeNames) { + selectedTextView.assertContainsText(name) + } } + /** + * Toggles the selection of the specified assignees. + * + * @param assigneeNames The list of assignee names to toggle. + */ fun toggleAssignees(assigneeNames: List) { - assigneeNames - .map { withTitle(it) } - .forEach { onView((withId(R.id.recyclerView))).perform(scrollToHolder(it), actionOnHolderItem(it, click())) } + assigneeNames.map { withTitle(it) } + .forEach { + onView(withId(R.id.recyclerView)).perform( + scrollToHolder(it), + actionOnHolderItem(it, click()) + ) + } } + /** + * Saves the selection and closes the Assignee List screen. + */ fun saveAndClose() { saveButton.click() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDetailsPage.kt index eb675a4faa..f8a5319490 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDetailsPage.kt @@ -28,6 +28,11 @@ import com.instructure.espresso.page.* import com.instructure.teacher.R import org.hamcrest.Matchers +/** + * Assignment details page + * + * @constructor Create empty Assignment details page + */ @Suppress("unused") class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { @@ -55,48 +60,94 @@ class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { private val ungradedDonutWrapper by OnViewWithId(R.id.ungradedWrapper, autoAssert = false) private val notSubmittedDonutWrapper by OnViewWithId(R.id.notSubmittedWrapper, autoAssert = false) + /** + * Assert that the description webview is visible within the content web view. + * + */ fun assertDisplaysInstructions() { scrollTo(R.id.contentWebView) descriptionWebView.assertVisible() } + /** + * Assert displays no instructions view. + * + */ fun assertDisplaysNoInstructionsView() { noDescriptionTextView.assertVisible() } + /** + * Open all dates page (by clicking on the due dates layout). + * + */ fun openAllDatesPage() { dueDatesLayout.click() } + /** + * Open edit page (by clicking on the Edit button). + * + */ fun openEditPage() { editButton.click() } + /** + * Open submissions page (by clicking on the View All Submissions button). + * + */ fun openSubmissionsPage() { scrollTo(R.id.viewAllSubmissions) viewAllSubmissions.click() } + /** + * Open graded submissions + * + */ fun openGradedSubmissions() { gradedDonutWrapper.click() } + /** + * Open ungraded submissions + * + */ fun openUngradedSubmissions() { ungradedDonutWrapper.click() } + /** + * Open not submitted submissions + * + */ fun openNotSubmittedSubmissions() { notSubmittedDonutWrapper.click() } + /** + * Assert assignment details + * + * @param assignment + */ fun assertAssignmentDetails(assignment: Assignment) { assertAssignmentDetails(assignment.name!!, assignment.published) } + /** + * Assert assignment details + * + * @param assignment + */ fun assertAssignmentDetails(assignment: AssignmentApiModel) { assertAssignmentDetails(assignment.name, assignment.published) } + /** + * Assert assignment closed + * + */ fun assertAssignmentClosed() { availableFromTextView.assertNotDisplayed() availableToTextView.assertNotDisplayed() @@ -104,49 +155,92 @@ class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { availabilityTextView.assertHasText(com.instructure.teacher.R.string.closed) } + /** + * Assert to filled and from empty + * + */ fun assertToFilledAndFromEmpty() { availableFromTextView.assertDisplayed().assertHasText(R.string.no_date_filler) availableToTextView.assertDisplayed().assertNotHasText(R.string.no_date_filler) } + /** + * Assert from filled and to empty + * + */ fun assertFromFilledAndToEmpty() { availableToTextView.assertDisplayed().assertHasText(R.string.no_date_filler) availableFromTextView.assertDisplayed().assertNotHasText(R.string.no_date_filler) } + /** + * Assert submission type none + * + */ fun assertSubmissionTypeNone() { scrollToSubmissionType() submissionTypesTextView.assertDisplayed().assertHasText(R.string.canvasAPI_none) } + /** + * Assert submission type on paper + * + */ fun assertSubmissionTypeOnPaper() { scrollToSubmissionType() submissionTypesTextView.assertDisplayed().assertHasText(R.string.canvasAPI_onPaper) } + /** + * Assert submission type online text entry + * + */ fun assertSubmissionTypeOnlineTextEntry() { scrollToSubmissionType() submissionTypesTextView.assertDisplayed().assertHasText(R.string.canvasAPI_onlineTextEntry) } + /** + * Assert submission type online url + * + */ fun assertSubmissionTypeOnlineUrl() { scrollToSubmissionType() submissionTypesTextView.assertDisplayed().assertHasText(R.string.canvasAPI_onlineURL) } + /** + * Assert submission type online upload + * + */ fun assertSubmissionTypeOnlineUpload() { scrollToSubmissionType() submissionTypesTextView.assertDisplayed().assertHasText(R.string.canvasAPI_onlineUpload) } + /** + * Assert assignment name changed + * + * @param newAssignmentName + */ fun assertAssignmentNameChanged(newAssignmentName: String) { assignmentNameTextView.assertHasText(newAssignmentName) } + /** + * Assert assignment points changed + * + * @param newAssignmentPoints + */ fun assertAssignmentPointsChanged(newAssignmentPoints: String) { pointsTextView.assertContainsText(newAssignmentPoints) } + /** + * Assert displays description + * + * @param text + */ fun assertDisplaysDescription(text: String) { descriptionWebView.assertVisible() Web.onWebView().withElement( @@ -156,21 +250,43 @@ class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { ) ).check(WebViewAssertions.webMatches(DriverAtoms.getText(), Matchers.comparesEqualTo(text))) } + /** + * Assert needs grading + * + * @param actual + * @param outOf + */ fun assertNeedsGrading(actual: Int = 1, outOf: Int = 1) { val resources = InstrumentationRegistry.getTargetContext() ungradedDonutWrapper.assertHasContentDescription(resources.getString(R.string.content_description_submission_donut_needs_grading).format(actual, outOf)) } + /** + * Assert not submitted + * + * @param actual + * @param outOf + */ fun assertNotSubmitted(actual: Int = 1, outOf: Int = 1) { val resources = InstrumentationRegistry.getTargetContext() notSubmittedDonutWrapper.assertHasContentDescription(resources.getString(R.string.content_description_submission_donut_unsubmitted).format(actual, outOf)) } + /** + * Assert has graded + * + * @param actual + * @param outOf + */ fun assertHasGraded(actual: Int =1, outOf: Int = 1) { val resources = InstrumentationRegistry.getTargetContext() gradedDonutWrapper.assertHasContentDescription(resources.getString(R.string.content_description_submission_donut_graded).format(actual, outOf)) } + /** + * View all submission + * + */ fun viewAllSubmission() { onView(withId(R.id.viewAllSubmissions)).click() } @@ -179,14 +295,27 @@ class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { scrollTo(R.id.submissionTypesTextView) } + /** + * Wait for render + * + */ fun waitForRender() { waitForView(withId(R.id.assignmentDetailsPage)) } + /** + * Refresh + * + */ fun refresh() { onView(withId(R.id.swipeRefreshLayout)).swipeDown() } + /** + * Assert published status + * + * @param published + */ fun assertPublishedStatus(published: Boolean) { if (published) { publishStatusTextView.assertHasText(R.string.published) @@ -195,10 +324,20 @@ class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { } } + /** + * Assert multiple due dates + * + */ fun assertMultipleDueDates() { onView(withId(R.id.otherDueDateTextView) + withText(R.string.multiple_due_dates)).assertDisplayed() } + /** + * Assert assignment details + * + * @param assignmentName + * @param published + */ private fun assertAssignmentDetails(assignmentName: String, published: Boolean) { assignmentNameTextView.assertHasText(assignmentName) assertPublishedStatus(published) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDueDatesPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDueDatesPage.kt index 11a8f521a4..b12fabb248 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDueDatesPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDueDatesPage.kt @@ -21,6 +21,11 @@ import com.instructure.espresso.page.* import com.instructure.teacher.R +/** + * Assignment due dates page + * + * @constructor Create empty Assignment due dates page + */ @Suppress("unused") class AssignmentDueDatesPage : BasePage(pageResId = R.id.dueDatesPage) { @@ -29,8 +34,16 @@ class AssignmentDueDatesPage : BasePage(pageResId = R.id.dueDatesPage) { private val editButton by OnViewWithId(R.id.menu_edit) private val recyclerView by WaitForViewWithId(R.id.recyclerView) + /** + * Open edit page (by clicking on Edit button). + * + */ fun openEditPage() = editButton.click() + /** + * Assert that the 'No Due Date' string is displayed as due date text. + * + */ fun assertDisplaysNoDueDate() { recyclerView.check(RecyclerViewItemCountAssertion(1)) assertLabelsDisplayedOnce() @@ -39,10 +52,19 @@ class AssignmentDueDatesPage : BasePage(pageResId = R.id.dueDatesPage) { onViewWithId(R.id.dueDateTextView).assertDisplayed().assertHasText(R.string.no_due_date) } + /** + * Assert that the due date count is the expected. + * + * @param expectedCount: The expected due date count parameter. + */ fun assertDueDatesCount(expectedCount: Int) { recyclerView.check(RecyclerViewItemCountAssertion(expectedCount)) } + /** + * Assert that the due date count is 1. + * + */ fun assertDisplaysSingleDueDate() { recyclerView.check(RecyclerViewItemCountAssertion(1)) assertLabelsDisplayedOnce() @@ -51,18 +73,37 @@ class AssignmentDueDatesPage : BasePage(pageResId = R.id.dueDatesPage) { onViewWithId(R.id.dueDateTextView).assertDisplayed().assertNotHasText(R.string.no_due_date) } + /** + * Assert that the 'Due For' text is the expected. + * + * @param dueForString: The expected due date integer parameter. + */ fun assertDueFor(dueForString: Int) { onView(withId(R.id.dueForTextView) + withText(dueForString)).assertDisplayed() } + /** + * Assert that the 'Due For' text is the expected. + * + * @param dueForString: The expected due date string parameter. + */ fun assertDueFor(dueForString: String) { onView(withId(R.id.dueForTextView) + withText(dueForString)).assertDisplayed() } + /** + * Assert that the 'Due Date' text is the expected. + * + * @param dueDateString: The expected due date string parameter. + */ fun assertDueDateTime(dueDateString: String) { onView(withId(R.id.dueDateTextView) + withText(dueDateString)) } + /** + * Assert displays availability dates. + * + */ fun assertDisplaysAvailabilityDates() { recyclerView.check(RecyclerViewItemCountAssertion(1)) assertLabelsDisplayedOnce() @@ -70,12 +111,20 @@ class AssignmentDueDatesPage : BasePage(pageResId = R.id.dueDatesPage) { onViewWithId(R.id.availableToTextView).assertDisplayed().assertNotHasText(R.string.no_date_filler) } + /** + * Assert that the corresponding labels ('For', 'Available from', 'Available to') are displayed. + * + */ private fun assertLabelsDisplayedOnce() { onViewWithText(R.string.details_due_for_label).assertDisplayed() onViewWithText(R.string.details_available_from_label).assertDisplayed() onViewWithText(R.string.details_available_to_label).assertDisplayed() } + /** + * Assert that no availability dates given. + * + */ private fun assertNoAvailabilityDates() { onViewWithId(R.id.availableFromTextView).assertDisplayed().assertHasText(R.string.no_date_filler) onViewWithId(R.id.availableToTextView).assertDisplayed().assertHasText(R.string.no_date_filler) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt index f212f9ff46..52738f29b0 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt @@ -35,58 +35,99 @@ import com.instructure.espresso.swipeDown import com.instructure.espresso.waitForCheck import com.instructure.teacher.R +/** + * AssignmentListPage represents a page that displays a list of assignments. + * It provides methods to interact with the assignment list, such as clicking on assignments, performing searches, and asserting the presence of assignments and grading periods. + * + * @constructor Creates an instance of the AssignmentListPage. + */ class AssignmentListPage : BasePage() { private val assignmentListToolbar by OnViewWithId(R.id.assignmentListToolbar) - private val assignmentRecyclerView by OnViewWithId(R.id.assignmentRecyclerView) - private val searchButton by OnViewWithId(R.id.search) - private val searchInput by WaitForViewWithId(androidx.appcompat.R.id.search_src_text) - - //Only displayed when assignment list is empty private val emptyPandaView by WaitForViewWithId(R.id.emptyPandaView) - - //Only displayed when there are grading periods private val gradingPeriodHeader by WaitForViewWithId(R.id.gradingPeriodContainer) + /** + * Clicks on the given assignment. + * + * @param assignment The assignment to click on. + */ fun clickAssignment(assignment: AssignmentApiModel) { waitForViewWithText(assignment.name).click() } + /** + * Clicks on the given assignment. + * + * @param assignment The assignment to click on. + */ fun clickAssignment(assignment: Assignment) { waitForViewWithText(assignment.name!!).click() } + /** + * Asserts that the "No Assignments" view is displayed. + */ fun assertDisplaysNoAssignmentsView() { emptyPandaView.assertDisplayed() } + /** + * Asserts that the given assignment is present in the list. + * + * @param assignment The assignment to check. + */ fun assertHasAssignment(assignment: Assignment) { assertAssignmentName(assignment.name!!) } + /** + * Asserts that the given assignment is present in the list. + * + * @param assignment The assignment to check. + */ fun assertHasAssignment(assignment: AssignmentApiModel) { assertAssignmentName(assignment.name) } + /** + * Asserts that grading periods are present. + */ fun assertHasGradingPeriods() { gradingPeriodHeader.assertDisplayed() } + /** + * Opens the search field. + */ fun openSearch() { searchButton.click() } + /** + * Enters the given search query. + * + * @param query The search query to enter. + */ fun enterSearchQuery(query: String) { searchInput.perform(ViewActions.replaceText(query)) } + /** + * Asserts the number of assignments in the list. + * + * @param count The expected number of assignments. + */ fun assertAssignmentCount(count: Int) { assignmentRecyclerView.waitForCheck(RecyclerViewItemCountAssertion(count)) } + /** + * Refreshes the assignment list. + */ fun refresh() { onView(withId(R.id.swipeRefreshLayout)).swipeDown() } @@ -95,6 +136,12 @@ class AssignmentListPage : BasePage() { waitForViewWithText(assignmentName).assertDisplayed() } + /** + * Asserts the "needs grading" count of the given assignment. + * + * @param assignmentName The name of the assignment to check. + * @param needsGradingCount The expected "needs grading" count. + */ fun assertNeedsGradingCountOfAssignment(assignmentName: String, needsGradingCount: Int) { onView(withId(R.id.ungradedCount) + withText("$needsGradingCount needs grading") + hasSibling(withId(R.id.assignmentTitle) + withText(assignmentName))).assertDisplayed() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentSubmissionListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentSubmissionListPage.kt index 7078de9763..05faae8c28 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentSubmissionListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentSubmissionListPage.kt @@ -27,156 +27,287 @@ import com.instructure.canvas.espresso.waitForMatcherWithRefreshes import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.canvasapi2.models.User import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.WaitForViewWithText +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertGone +import com.instructure.espresso.assertHasText +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.waitForViewWithId +import com.instructure.espresso.page.waitForViewWithText +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withDescendant +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText import com.instructure.teacher.R import org.hamcrest.CoreMatchers.allOf import org.hamcrest.Matchers +/** + * Represents a page for managing assignment submissions. + * + * This class extends the `BasePage` class and provides methods for interacting with various elements on the page. + * It contains functions for asserting the presence of specific views, clicking on elements, and performing filter actions. + * + * @constructor Creates an instance of the `AssignmentSubmissionListPage` class. + */ class AssignmentSubmissionListPage : BasePage() { private val assignmentSubmissionListToolbar by OnViewWithId(R.id.assignmentSubmissionListToolbar) - private val assignmentSubmissionRecyclerView by OnViewWithId(R.id.submissionsRecyclerView) - private val assignmentSubmissionListFilterLabel by OnViewWithId(R.id.filterTitle) - private val assignmentSubmissionClearFilter by OnViewWithId(R.id.clearFilterTextView, false) - private val assignmentSubmissionFilterButton by OnViewWithId(R.id.submissionFilter, false) - private val assignmentSubmissionFilterBySubmissionsButton by WaitForViewWithText(R.string.filterSubmissionsLowercase) - private val assignmentSubmissionStatus by OnViewWithId(R.id.submissionStatus) - private val addMessageFAB by OnViewWithId(R.id.addMessage) - private val enableAnonymousGradingMenuItem by WaitForViewWithText(R.string.turnOnAnonymousGrading) - private val disableAnonymousGradingMenuItem by WaitForViewWithText(R.string.turnOffAnonymousGrading) - private val anonStatusView by WaitForViewWithId(R.id.anonGradingStatusView) - - //Only displayed when assignment list is empty private val emptyPandaView by WaitForViewWithId(R.id.emptyPandaView) + /** + * Assert displays no submissions view + * + */ fun assertDisplaysNoSubmissionsView() { onView(withText("No items") + withAncestor(R.id.emptyPandaView)).assertDisplayed() } + /** + * Assert has student submission + * + * @param canvasUser + */ fun assertHasStudentSubmission(canvasUser: CanvasUserApiModel) { waitForViewWithText(canvasUser.name).assertDisplayed() } + /** + * Assert filter label all submissions + * + */ fun assertFilterLabelAllSubmissions() { assignmentSubmissionListFilterLabel.assertHasText(R.string.all_submissions) } + /** + * Click on post policies + * + */ fun clickOnPostPolicies() { waitForViewWithId(R.id.menuPostPolicies).click() } + /** + * Assert displays clear filter + * + */ fun assertDisplaysClearFilter() { assignmentSubmissionClearFilter.assertDisplayed() } + /** + * Assert clear filter gone + * + */ fun assertClearFilterGone() { assignmentSubmissionClearFilter.assertGone() } + /** + * Assert student has grade + * + * @param grade + */ fun assertStudentHasGrade(grade: String) { onView(withId(R.id.submissionGrade)).assertHasText(grade) } + /** + * Click filter button + * + */ fun clickFilterButton() { assignmentSubmissionFilterButton.click() } + /** + * Click filter submissions + * + */ fun clickFilterSubmissions() { assignmentSubmissionFilterBySubmissionsButton.click() } + /** + * Click submission + * + * @param student + */ fun clickSubmission(student: CanvasUserApiModel) { waitForMatcherWithRefreshes(withText(student.name)) waitForViewWithText(student.name).click() } + /** + * Click submission + * + * @param student + */ fun clickSubmission(student: User) { waitForMatcherWithRefreshes(withId(R.id.submissionsRecyclerView)) scrollRecyclerView(R.id.submissionsRecyclerView, student.name) waitForViewWithText(student.name).click() } + /** + * Click filter submitted late + * + */ fun clickFilterSubmittedLate() { - onView(withText(R.string.submitted_late)).perform(withCustomConstraints(click(), isDisplayingAtLeast(50))) + waitForView(withText(R.string.submitted_late)).perform(withCustomConstraints(click(), isDisplayingAtLeast(50))) } + /** + * Click filter ungraded + * + */ fun clickFilterUngraded() { - onView(withText(R.string.not_graded)).perform(withCustomConstraints(click(), isDisplayingAtLeast(50))) + waitForView(withText(R.string.not_graded)).perform(withCustomConstraints(click(), isDisplayingAtLeast(50))) } + /** + * Assert filter label text + * + * @param text + */ fun assertFilterLabelText(text: Int) { assignmentSubmissionListFilterLabel.assertHasText(text) } + /** + * Assert has submission + * + * @param expectedCount + */ fun assertHasSubmission(expectedCount: Int = 1) { assignmentSubmissionRecyclerView.check(RecyclerViewItemCountAssertion(expectedCount)) } + /** + * Assert has no submission + * + */ fun assertHasNoSubmission() { assignmentSubmissionRecyclerView.check(RecyclerViewItemCountAssertion(0)) } + /** + * Assert submission status missing + * + */ fun assertSubmissionStatusMissing() { assignmentSubmissionStatus.assertHasText(R.string.submission_status_missing) } + /** + * Assert submission status submitted + * + */ fun assertSubmissionStatusSubmitted() { assignmentSubmissionStatus.assertHasText(R.string.submission_status_submitted) } + /** + * Assert submission status not submitted + * + */ fun assertSubmissionStatusNotSubmitted() { assignmentSubmissionStatus.assertHasText(R.string.submission_status_not_submitted) } + /** + * Assert submission status late + * + */ fun assertSubmissionStatusLate() { assignmentSubmissionStatus.assertHasText(R.string.submission_status_late) } + /** + * Click add message + * + */ fun clickAddMessage() { addMessageFAB.click() } + /** + * Assert displays enable anonymous option + * + */ fun assertDisplaysEnableAnonymousOption() { enableAnonymousGradingMenuItem.assertDisplayed() } + /** + * Assert displays disable anonymous option + * + */ fun assertDisplaysDisableAnonymousOption() { disableAnonymousGradingMenuItem.assertDisplayed() } + /** + Clicks on the "Enable Anonymous Grading" option. + */ fun clickAnonymousOption() { enableAnonymousGradingMenuItem.click() } + /** + Asserts that the "Anonymous Grading" status view is displayed. + */ fun assertDisplaysAnonymousGradingStatus() { anonStatusView.assertHasText(R.string.anonymousGradingLabel) } + /** + Asserts that the "Anonymous Name" is displayed. + */ fun assertDisplaysAnonymousName() { waitForViewWithId(R.id.studentName).assertHasText(R.string.anonymousStudentLabel) } + /** + Clicks on the "OK" button in the filter dialog. + */ fun clickFilterDialogOk() { waitForViewWithText(android.R.string.ok).click() } - + /** + * + * Asserts that the file with the given filename is displayed. + * @param fileName The name of the file. + */ fun assertFileDisplayed(fileName: String) { val matcher = Matchers.allOf(ViewMatchers.withId(R.id.fileNameText), ViewMatchers.withText(fileName)) Espresso.onView(matcher).assertDisplayed() } + /** + Asserts that the comment attachment with the given filename and display name is displayed. + @param fileName The name of the attachment file. + @param displayName The display name of the attachment. + */ fun assertCommentAttachmentDisplayedCommon(fileName: String, displayName: String) { val commentMatcher = Matchers.allOf( ViewMatchers.withId(R.id.commentHolder), @@ -196,7 +327,23 @@ class AssignmentSubmissionListPage : BasePage() { onView(commentMatcher).assertDisplayed() } + /** + * Asserts that the grades are hidden for the student with the given name. + * + * @param studentName The name of the student. + */ fun assertGradesHidden(studentName: String) { - onView(allOf(withId(R.id.studentName), withText(studentName), withAncestor(allOf(withId(R.id.submissionsRecyclerView), withDescendant(withId(R.id.hiddenIcon)))))).check(matches(isDisplayed())) + onView( + allOf( + withId(R.id.studentName), + withText(studentName), + withAncestor( + allOf( + withId(R.id.submissionsRecyclerView), + withDescendant(withId(R.id.hiddenIcon)) + ) + ) + ) + ).check(matches(isDisplayed())) } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CalendarEventPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CalendarEventPage.kt index cc272091fd..6d078ce3c2 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CalendarEventPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CalendarEventPage.kt @@ -28,12 +28,33 @@ import com.instructure.espresso.page.BasePage import com.instructure.teacher.R import org.hamcrest.Matchers +/** + * Represents a page displaying a calendar event. + * + * This class extends the `BasePage` class and provides methods for verifying the title and description + * of the calendar event. + * + * @param pageResourceId The resource ID of the calendar event page. + * @constructor Creates an instance of the `CalendarEventPage` class. + */ class CalendarEventPage : BasePage(R.id.fragmentCalendarEvent) { + /** + * Verifies that the title of the calendar event matches the specified title. + * + * @param title The expected title of the calendar event. + * @throws AssertionError if the title does not match the expected title. + */ fun verifyTitle(title: String) { Espresso.onView(Matchers.allOf(ViewMatchers.withParent(ViewMatchers.withId(R.id.toolbar)), containsTextCaseInsensitive(title))).assertDisplayed() } + /** + * Verifies that the description of the calendar event matches the specified description. + * + * @param description The expected description of the calendar event. + * @throws AssertionError if the description does not match the expected description. + */ fun verifyDescription(description: String) { Web.onWebView(ViewMatchers.withId(R.id.contentWebView)) .withElement(DriverAtoms.findElement(Locator.ID, "content")) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ChooseRecipientsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ChooseRecipientsPage.kt index d8f246a3a5..c955af828d 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ChooseRecipientsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ChooseRecipientsPage.kt @@ -25,36 +25,72 @@ import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.waitForViewWithText import com.instructure.teacher.R -class ChooseRecipientsPage: BasePage() { +/** + * Represents a page for choosing recipients. + * + * This class extends the `BasePage` class and provides methods for asserting page objects, checking for the presence of students, + * and interacting with the page by clicking on various elements. + * + * @constructor Creates an instance of the `ChooseRecipientsPage` class. + */ +class ChooseRecipientsPage : BasePage() { private val toolbar by WaitForViewWithId(R.id.toolbar) private val recyclerView by WaitForViewWithId(R.id.recyclerView) private val menuDone by WaitForViewWithId(R.id.menuDone) private val checkBox by WaitForViewWithId(R.id.checkBox) + /** + * Asserts the presence of page objects within a specified duration. + * + * @param duration The duration to wait for the page objects to appear. + * @throws AssertionError if any of the page objects fail to appear within the specified duration. + */ override fun assertPageObjects(duration: Long) { toolbar.assertDisplayed() recyclerView.assertDisplayed() menuDone.assertDisplayed() } + /** + * Asserts that the page has the "Students" category displayed. + * + * @throws AssertionError if the "Students" category is not displayed. + */ fun assertHasStudent() { waitForViewWithText("Students").assertDisplayed() } + /** + * Clicks the "Done" menu item. + */ fun clickDone() { menuDone.click() } + /** + * Clicks the "Students" category. + */ fun clickStudentCategory() { waitForViewWithText("Students").click() } + /** + * Clicks on a student with the provided User object. + * + * @param student The User object representing the student to click. + */ fun clickStudent(student: User) { waitForViewWithText(student.shortName!!).click() } + /** + * Clicks on a student with the provided CanvasUserApiModel object. + * + * @param student The CanvasUserApiModel object representing the student to click. + */ fun clickStudent(student: CanvasUserApiModel) { waitForViewWithText(student.shortName).click() } } + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CommentLibraryPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CommentLibraryPage.kt index f09037b268..1f1d2183a0 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CommentLibraryPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CommentLibraryPage.kt @@ -27,38 +27,81 @@ import com.instructure.espresso.page.onViewWithContentDescription import com.instructure.espresso.page.onViewWithText import com.instructure.teacher.R +/** + * Represents a page for the comment library. + * + * This class extends the `BasePage` class and provides methods for asserting the visibility and count of suggestions, + * checking the visibility of the suggestion list and empty view, selecting a suggestion, and closing the comment library. + * + * @constructor Creates an instance of the `CommentLibraryPage` class. + */ class CommentLibraryPage : BasePage(R.id.commentLibraryRoot) { private val toolbar by OnViewWithId(R.id.commentLibraryToolbar) private val recyclerView by OnViewWithId(R.id.commentLibraryRecyclerView) private val emptyView by OnViewWithId(R.id.commentLibraryEmtpyView, autoAssert = false) + /** + * Asserts the visibility of a suggestion with the provided text. + * + * @param suggestion The text of the suggestion to be asserted. + * @throws AssertionError if the suggestion is not visible. + */ fun assertSuggestionVisible(suggestion: String) { onViewWithText(suggestion).assertDisplayed() } + /** + * Asserts the count of suggestions in the comment library. + * + * @param expectedCount The expected count of suggestions. + * @throws AssertionError if the actual count of suggestions does not match the expected count. + */ fun assertSuggestionsCount(expectedCount: Int) { recyclerView.check(RecyclerViewItemCountAssertion(expectedCount)) } + /** + * Asserts that the count of suggestions in the comment library is greater than the provided count. + * + * @param count The count to compare against. + * @throws AssertionError if the count of suggestions is not greater than the provided count. + */ fun assertSuggestionsCountGreaterThan(count: Int) { recyclerView.check(RecyclerViewItemCountGreaterThanAssertion(count)) } + /** + * Asserts that the suggestion list is not visible. + * + * @throws AssertionError if the suggestion list is visible. + */ fun assertSuggestionListNotVisible() { recyclerView.assertNotDisplayed() } + /** + * Asserts the visibility of the empty view in the comment library. + * + * @throws AssertionError if the empty view is not visible. + */ fun assertEmptyViewVisible() { emptyView.assertDisplayed() } + /** + * Selects a suggestion with the provided text. + * + * @param suggestion The text of the suggestion to be selected. + */ fun selectSuggestion(suggestion: String) { onViewWithText(suggestion).click() } + /** + * Closes the comment library. + */ fun closeCommentLibrary() { onViewWithContentDescription(R.string.close).click() } - -} \ No newline at end of file +} diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseBrowserPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseBrowserPage.kt index 94fde39358..840968381c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseBrowserPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseBrowserPage.kt @@ -26,12 +26,32 @@ import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast import com.instructure.canvas.espresso.withCustomConstraints -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.TextViewColorAssertion +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForViewWithText +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText +import com.instructure.espresso.swipeDown +import com.instructure.espresso.waitForCheck import com.instructure.teacher.R import com.instructure.teacher.holders.CourseBrowserViewHolder import org.hamcrest.Matchers.allOf +/** + * Represents the course browser page (aka. when you opens a course from the dashboard, you can see the Course Browser Page). + * + * This class extends the `BasePage` class and provides methods for interacting with the course browser, + * such as opening different tabs, clicking the settings button, refreshing the page, and asserting the visibility + * of the course browser page and specific elements. + * + * @constructor Creates an instance of the `CourseBrowserPage` class. + */ class CourseBrowserPage : BasePage() { // TODO: Add recycler view scrolling to support small screen size devices. @@ -42,6 +62,9 @@ class CourseBrowserPage : BasePage() { private val courseSettingsMenuButton by OnViewWithId(R.id.menu_course_browser_settings) private val magicNumberForScroll = 10 + /** + * Opens the assignments tab in the course browser. + */ fun openAssignmentsTab() { scrollOpen("Assignments", scrollPosition = magicNumberForScroll) } @@ -52,40 +75,65 @@ class CourseBrowserPage : BasePage() { visible, causing some clicks to fail. We need to perform a swipe up first to make it fully visible. */ Espresso.onView(ViewMatchers.withId(android.R.id.content)).perform(ViewActions.swipeUp()) Espresso.onView(ViewMatchers.withId(R.id.courseBrowserRecyclerView)) - .perform(scrollToPosition(scrollPosition)) + .perform(scrollToPosition(scrollPosition)) } + + /** + * Opens the quizzes tab in the course browser. + */ fun openQuizzesTab() { scrollDownToCourseBrowser(scrollPosition = magicNumberForScroll) waitForViewWithText(R.string.tab_quizzes).click() } + /** + * Opens the discussions tab in the course browser. + */ fun openDiscussionsTab() { scrollOpen(textName = "Discussions", scrollPosition = 1) } + /** + * Opens the announcements tab in the course browser. + */ fun openAnnouncementsTab() { scrollOpen("Announcements", scrollPosition = magicNumberForScroll) } + /** + * Opens the people tab in the course browser. + */ fun openPeopleTab() { scrollOpen("People", scrollPosition = 3) } + /** + * Clicks the settings button in the course browser. + */ fun clickSettingsButton() { courseSettingsMenuButton.click() } + /** + * Opens the pages tab in the course browser. + */ fun openPagesTab() { scrollDownToCourseBrowser(scrollPosition = magicNumberForScroll) waitForViewWithText(R.string.tab_pages).click() } + /** + * Opens the syllabus in the course browser. + */ fun openSyllabus() { scrollDownToCourseBrowser(scrollPosition = magicNumberForScroll) waitForViewWithText("Syllabus").click() } + /** + * Opens the modules tab in the course browser. + */ fun openModulesTab() { //modules sits at the end of the list, so on smaller resolutions it may be necessary to scroll down twitce scrollDownToCourseBrowser(scrollPosition = magicNumberForScroll) @@ -93,11 +141,19 @@ class CourseBrowserPage : BasePage() { waitForViewWithText("Modules").click() } + /** + * Asserts that the course browser page is displayed. + * + * @throws AssertionError if the course browser page is not displayed. + */ fun assertCourseBrowserPageDisplayed() { onView(withId(R.id.courseBrowserRecyclerView)).assertDisplayed() onView(withId(R.id.courseBrowserTitle)).assertDisplayed() } + /** + * Refreshes the course browser page. + */ fun refresh() { onView(allOf(withId(R.id.swipeRefreshLayout))).swipeDown() } @@ -116,15 +172,32 @@ class CourseBrowserPage : BasePage() { } } + /** + * Waits for the course browser to finish rendering. + */ fun waitForRender() { onView(withId(R.id.menu_course_browser_settings)).waitForCheck(ViewAssertions.matches(ViewMatchers.isDisplayed())) } + /** + * Asserts the course title displayed in the course browser. + * + * @param courseTitle The expected course title. + * @throws AssertionError if the course title is not displayed or does not match the expected title. + */ fun assertCourseTitle(courseTitle: String) { onView(withId(R.id.courseBrowserTitle) + withText(courseTitle)).assertDisplayed() } + /** + * Asserts the text color of a tab label in the course browser. + * + * @param tabTitle The title of the tab. + * @param expectedColor The expected text color of the tab label. + * @throws AssertionError if the text color of the tab label does not match the expected color. + */ fun assertTabLabelTextColor(tabTitle: String, expectedColor: String) { onView(ViewMatchers.withText(tabTitle)).check(TextViewColorAssertion(expectedColor)) } } + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseSettingsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseSettingsPage.kt index 8aa7db48f3..b01492d45b 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseSettingsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseSettingsPage.kt @@ -30,6 +30,15 @@ import com.instructure.espresso.replaceText import com.instructure.teacher.R import org.hamcrest.Matchers.`is` +/** + * Represents a page for the course settings. + * + * This class extends the `BasePage` class and provides methods for interacting with the course settings, + * such as clicking on the course name, editing the course name, clicking on the set home page option, + * selecting a new home page, and asserting the changes in the home page and course name. + * + * @constructor Creates an instance of the `CourseSettingsPage` class. + */ class CourseSettingsPage : BasePage() { private val courseImage by OnViewWithId(R.id.courseImage) @@ -40,10 +49,19 @@ class CourseSettingsPage : BasePage() { private val courseNameText by OnViewWithId(R.id.courseName) private val toolbar by OnViewWithId(R.id.toolbar) + /** + * Clicks on the course name. + */ fun clickCourseName() { editCourseNameRootView.click() } + /** + * Edits the course name with a new name. + * + * @param newName The new name for the course. + * @return The new name for the course. + */ fun editCourseName(newName: String): String { val dialogNameEntry = onViewWithId(R.id.newCourseName) val dialogOkButton = onViewWithText(android.R.string.ok) @@ -52,14 +70,22 @@ class CourseSettingsPage : BasePage() { return newName } + /** + * Clicks on the set home page option. + */ fun clickSetHomePage() { editHomeRootView.click() } + /** + * Selects a new home page and returns its string representation. + * + * @return The string representation of the new home page. + */ fun selectNewHomePage(): String { var newHomePageString = "" val unselectedRadioButton = - onView(checked(false) { newHomePageString = it }) + onView(checked(false) { newHomePageString = it }) val dialogOkButton = onViewWithText(android.R.string.ok) unselectedRadioButton.click() dialogOkButton.click() @@ -67,16 +93,35 @@ class CourseSettingsPage : BasePage() { return newHomePageString } + /** + * Asserts that the home page has been changed to the specified value. + * + * @param newHomePage The expected new home page value. + * @throws AssertionError if the home page does not match the expected value. + */ fun assertHomePageChanged(newHomePage: String) { courseHomePageText.assertHasText(newHomePage) } + /** + * Asserts that the course name has been changed to the specified value. + * + * @param newCourseName The expected new course name. + * @throws AssertionError if the course name does not match the expected value. + */ fun assertCourseNameChanged(newCourseName: String) { courseNameText.assertHasText(newCourseName) assertToolbarSubtitleHasText(newCourseName) } - fun assertToolbarSubtitleHasText(newCourseName: String) { + /** + * Asserts that the toolbar subtitle has the specified text. + * + * @param newCourseName The expected text for the toolbar subtitle. + * @throws AssertionError if the toolbar subtitle text does not match the expected value. + */ + private fun assertToolbarSubtitleHasText(newCourseName: String) { toolbar.check(matches(matchToolbarText(`is`(newCourseName), false))) } } + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DashboardPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DashboardPage.kt index a6e2fd135c..34bdf1ef51 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DashboardPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DashboardPage.kt @@ -47,6 +47,16 @@ import com.instructure.teacher.R import org.hamcrest.CoreMatchers.allOf import org.hamcrest.Matcher +/** + * Represents a page for the dashboard. + * + * This class extends the `BasePage` class and provides methods for interacting with the dashboard, + * such as asserting the display of courses, empty view, and course title; opening and switching courses; + * clicking on the edit dashboard button and course overflow menu; changing the course nickname; + * asserting the display of notifications; and opening the inbox and todo tabs. + * + * @constructor Creates an instance of the `DashboardPage` class. + */ class DashboardPage : BasePage() { private val toolbar by OnViewWithId(R.id.toolbar) @@ -60,11 +70,19 @@ class DashboardPage : BasePage() { private val coursesTab by WaitForViewWithId(R.id.tab_courses) private val todoTab by WaitForViewWithId(R.id.tab_todo) private val inboxTab by WaitForViewWithId(R.id.tab_inbox) - private val previousLoginTitleText by OnViewWithId(R.id.previousLoginTitleText, autoAssert = false) + private val previousLoginTitleText by OnViewWithId( + R.id.previousLoginTitleText, + autoAssert = false + ) private val hamburgerButtonMatcher = allOf(withContentDescription(R.string.navigation_drawer_open), isDisplayed()) + /** + * Asserts that the course specified by [course] is displayed in the dashboard. + * + * @param course The course to be displayed. + */ fun assertDisplaysCourse(course: CourseApiModel) { val matcher = allOf( withText(course.name), @@ -74,6 +92,11 @@ class DashboardPage : BasePage() { scrollAndAssertDisplayed(matcher) } + /** + * Asserts that the course with the specified [courseName] is displayed in the dashboard. + * + * @param courseName The name of the course to be displayed. + */ fun assertDisplaysCourse(courseName: String) { val matcher = allOf( withText(courseName), @@ -83,10 +106,24 @@ class DashboardPage : BasePage() { scrollAndAssertDisplayed(matcher) } + /** + * Asserts that all the courses in the specified [mCourses] list are displayed in the dashboard. + * + * @param mCourses The list of courses to be displayed. + */ fun assertHasCourses(mCourses: List) { - for (course in mCourses) onView(withId(R.id.titleTextView) + withText(course.name) + withAncestor(R.id.swipeRefreshLayout)).assertDisplayed() + for (course in mCourses) onView( + withId(R.id.titleTextView) + withText(course.name) + withAncestor( + R.id.swipeRefreshLayout + ) + ).assertDisplayed() } + /** + * Asserts that the course specified by [course] is not displayed in the dashboard. + * + * @param course The course to be checked. + */ fun assertCourseNotDisplayed(course: CourseApiModel) { val matcher = allOf( withText(course.name), @@ -96,14 +133,30 @@ class DashboardPage : BasePage() { onView(matcher).check(doesNotExist()) } + /** + * Asserts that the empty view is displayed in the dashboard. + */ fun assertEmptyView() { emptyView.assertDisplayed() } + /** + * Asserts that the course title specified by [courseTitle] is displayed in the dashboard. + * + */ + + /** + * Asserts that the course title specified by [courseTitle] is displayed in the dashboard. + * + * @param courseTitle The title of the course to be displayed. + */ fun assertCourseTitle(courseTitle: String) { onView(withId(R.id.titleTextView) + withText(courseTitle) + withAncestor(R.id.swipeRefreshLayout)).assertDisplayed() } + /** + * Asserts that the dashboard displays the courses, including the toolbar, courses view, and edit dashboard button. + */ fun assertDisplaysCourses() { emptyView.assertNotDisplayed() onView(withParent(R.id.toolbar) + withText(R.string.dashboard)).assertDisplayed() @@ -111,24 +164,47 @@ class DashboardPage : BasePage() { editDashboardButton.assertDisplayed() } + /** + * Asserts that the specified course is displayed and opens it. + * + * @param course The course to be displayed and opened. + */ fun assertOpensCourse(course: CourseApiModel) { assertDisplaysCourse(course) openCourse(courseName = course.name) onView(withId(R.id.courseBrowserTitle)).assertContainsText(course.name) } + /** + * Clicks on the edit dashboard button. + */ fun clickEditDashboard() { onView(withId(R.id.editDashboardTextView)).click() } + /** + * Opens the course with the specified [courseName]. + * + * @param courseName The name of the course to be opened. + */ fun openCourse(courseName: String) { onView(withText(courseName)).click() } + /** + * Opens the specified [course]. + * + * @param course The course to be opened. + */ fun openCourse(course: CourseApiModel) { onView(withText(course.name)).click() } + /** + * Opens the specified [course]. + * + * @param course The course to be opened. + */ fun openCourse(course: Course) { onView(withText(course.name)).click() } @@ -137,48 +213,98 @@ class DashboardPage : BasePage() { onView(matcher).assertDisplayed() } + /** + * Waits for the dashboard to finish rendering. + */ fun waitForRender() { onView(hamburgerButtonMatcher).waitForCheck(matches(isDisplayed())) } + /** + * Opens the inbox tab. + */ fun openInbox() { inboxTab.click() } + /** + * Opens the todo tab. + */ fun openTodo() { todoTab.click() } + /** + * Asserts that the course label text color matches the specified [expectedTextColor]. + * + * @param expectedTextColor The expected text color for the course label. + */ fun assertCourseLabelTextColor(expectedTextColor: String) { onView(withId(R.id.courseLabel)).check(TextViewColorAssertion(expectedTextColor)) } + /** + * Selects the specified [course] in the dashboard. + * + * @param course The course to be selected. + */ fun selectCourse(course: CourseApiModel) { assertDisplaysCourse(course) onView(withText(course.name)).click() } + /** + * Switches the course view in the dashboard. + */ fun switchCourseView() { onView(withId(R.id.menu_dashboard_cards)).click() } + /** + * Clicks on the overflow menu of the specified [courseTitle] and selects the menu item with the specified [menuTitle]. + * + * @param courseTitle The title of the course. + * @param menuTitle The title of the menu item. + */ fun clickCourseOverflowMenu(courseTitle: String, menuTitle: String) { - val courseOverflowMatcher = withId(R.id.overflow) + withAncestor(withId(R.id.cardView) + withDescendant(withId(R.id.titleTextView) + withText(courseTitle))) + val courseOverflowMatcher = withId(R.id.overflow)+withAncestor(withId(R.id.cardView)+withDescendant(withId(R.id.titleTextView)+withText(courseTitle))) onView(courseOverflowMatcher).click() waitForView(withId(R.id.title) + withText(menuTitle)).click() } + /** + * Changes the course nickname to the specified [changeTo]. + * + * @param changeTo The new nickname for the course. + */ fun changeCourseNickname(changeTo: String) { onView(withId(R.id.newCourseNickname)).replaceText(changeTo) onView(withText(R.string.ok) + withAncestor(R.id.buttonPanel)).click() } + /** + * Asserts that the specified [accountNotification] is displayed in the dashboard. + * + * @param accountNotification The account notification to be displayed. + */ fun assertNotificationDisplayed(accountNotification: AccountNotification) { - onView(withId(R.id.announcementTitle) + withAncestor(R.id.announcementContainer) + withText(accountNotification.subject)).assertDisplayed() + onView( + withId(R.id.announcementTitle) + withAncestor(R.id.announcementContainer) + withText( + accountNotification.subject + ) + ).assertDisplayed() } + /** + * Clicks on the specified [accountNotification] in the dashboard. + * + * @param accountNotification The account notification to be clicked. + */ fun clickOnNotification(accountNotification: AccountNotification) { - onView(withId(R.id.announcementTitle) + withAncestor(R.id.announcementContainer) + withText(accountNotification.subject)).click() + onView( + withId(R.id.announcementTitle) + withAncestor(R.id.announcementContainer) + withText( + accountNotification.subject + ) + ).click() } - } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsDetailsPage.kt index 2a8fcdd343..65f2600f8d 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsDetailsPage.kt @@ -30,36 +30,64 @@ import com.instructure.teacher.ui.utils.TypeInRCETextEditor class DiscussionsDetailsPage : BasePage() { + /** + * Asserts that the discussion has the specified [title]. + * + * @param title The title of the discussion to be asserted. + */ fun assertDiscussionTitle(title: String) { onView(withId(R.id.discussionTopicTitle)).assertHasText(title) } + /** + * Asserts that the discussion is published. + */ fun assertDiscussionPublished() { checkPublishedTextView("Published") } + /** + * Asserts that the discussion is unpublished. + */ fun assertDiscussionUnpublished() { checkPublishedTextView("Unpublished") } + /** + * Asserts that there are no replies in the discussion. + */ fun assertNoReplies() { onView(withId(R.id.discussionTopicReplies)).assertNotDisplayed() } + /** + * Asserts that the discussion has at least one reply. + */ fun assertHasReply() { val repliesHeader = onView(withId(R.id.discussionTopicReplies)) repliesHeader.scrollTo() repliesHeader.assertDisplayed() } + /** + * Opens the edit menu of the discussion. + */ fun openEdit() { onView(withId(R.id.menu_edit)).click() } + /** + * Refreshes the discussion page. + */ fun refresh() { onView(withId(R.id.swipeRefreshLayout)).swipeDown() } + /** + * Adds a reply with the specified [content] to the discussion. + * + * @param content The content of the reply. + */ fun addReply(content: String) { onView(withId(R.id.replyToDiscussionTopic)).click() onView(withId(R.id.rce_webView)).perform(TypeInRCETextEditor(content)) @@ -68,6 +96,5 @@ class DiscussionsDetailsPage : BasePage() { private fun checkPublishedTextView(status: String) { onView(withId(R.id.publishStatusTextView)).assertHasText(status) - } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsListPage.kt index fc7795bcb9..87394c2577 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsListPage.kt @@ -40,6 +40,9 @@ import com.instructure.espresso.swipeDown import com.instructure.espresso.waitForCheck import com.instructure.teacher.R +/** + * Represents the Discussions List page. + */ class DiscussionsListPage : BasePage() { private val discussionListToolbar by OnViewWithId(R.id.discussionListToolbar) @@ -48,58 +51,118 @@ class DiscussionsListPage : BasePage() { private val searchButton by OnViewWithId(R.id.search) private val searchInput by WaitForViewWithId(androidx.appcompat.R.id.search_src_text) + /** + * Clicks on the specified [discussion] in the discussions list. + * + * @param discussion The discussion to be clicked. + */ fun clickDiscussion(discussion: DiscussionApiModel) { waitForViewWithText(discussion.title).click() } + /** + * Clicks on the discussion with the specified [discussionTitle] in the discussions list. + * + * @param discussionTitle The title of the discussion to be clicked. + */ fun clickDiscussion(discussionTitle: String) { waitForViewWithText(discussionTitle).click() } + /** + * Asserts that the discussions list contains the specified [discussion]. + * + * @param discussion The discussion to be asserted. + */ fun assertHasDiscussion(discussion: DiscussionApiModel) { waitForViewWithText(discussion.title).assertDisplayed() } + /** + * Asserts that the discussions list contains a discussion with the specified [discussionTitle]. + * + * @param discussionTitle The title of the discussion to be asserted. + */ fun assertHasDiscussion(discussionTitle: String) { waitForViewWithText(discussionTitle).assertDisplayed() } + /** + * Asserts that the discussion with the specified [discussionTitle] does not exist in the discussions list. + * + * @param discussionTitle The title of the discussion to be asserted. + */ fun assertDiscussionDoesNotExist(discussionTitle: String) { onView(withText(discussionTitle)).check(doesNotExist()) } + /** + * Asserts that no discussions are present in the discussions list. + */ fun assertNoDiscussion() { onView(withId(R.id.emptyPandaView)).assertDisplayed() } + /** + * Asserts that a discussion with the specified [discussion] is present in the discussions list. + * + * @param discussion The discussion to be asserted. + */ fun assertHasDiscussion(discussion: DiscussionTopicHeader) { waitForViewWithText(discussion.title!!).assertDisplayed() } + /** + * Opens the search functionality in the discussions list. + */ fun openSearch() { searchButton.click() } + /** + * Enters the specified [query] into the search input field. + * + * @param query The search query to be entered. + */ fun enterSearchQuery(query: String) { searchInput.perform(ViewActions.replaceText(query)) } + /** + * Asserts the number of discussions in the discussions list. + * + * @param count The expected number of discussions. + */ fun assertDiscussionCount(count: Int) { discussionsRecyclerView.waitForCheck(RecyclerViewItemCountAssertion(count)) } + /** + * Refreshes the discussions list. + */ fun refresh() { onView(withId(R.id.swipeRefreshLayout)).swipeDown() } + /** + * Creates a new discussion. + */ fun createNewDiscussion() { onView(withId(R.id.createNewDiscussion)).click() } + /** + * Toggles the collapse/expand icon in the discussions list. + */ fun toggleCollapseExpandIcon() { onView(withId(R.id.collapseIcon)).click() } + /** + * Clicks on the overflow menu for the discussion with the specified [discussionTitle]. + * + * @param discussionTitle The title of the discussion. + */ fun clickDiscussionOverFlowMenu(discussionTitle: String) { waitForView(withId(R.id.discussionOverflow) + ViewMatchers.hasSibling( withId(R.id.discussionTitle) + withText( @@ -109,21 +172,42 @@ class DiscussionsListPage : BasePage() { ).click() } + /** + * Selects the specified [menuText] from the overflow menu. + * + * @param menuText The text of the menu item to be selected. + */ fun selectOverFlowMenu(menuText: String) { waitForView(withText(menuText) + withParent(R.id.coursePages)).click() onView(withText(R.string.ok) + withAncestor(R.id.buttonPanel)).click() } + /** + * Deletes the discussion with the specified [discussionTitle] from the overflow menu. + * + * @param discussionTitle The title of the discussion to be deleted. + */ fun deleteDiscussionFromOverflowMenu(discussionTitle: String) { clickDiscussionOverFlowMenu(discussionTitle) selectOverFlowMenu("Delete") onView(withId(android.R.id.button1) + withText(R.string.delete)).click() } + /** + * Asserts that a group with the specified [groupName] is displayed in the discussions list. + * + * @param groupName The name of the group to be asserted. + */ fun assertGroupDisplayed(groupName: String) { waitForView(withId(R.id.groupName) + withText(groupName)).assertDisplayed() } + /** + * Asserts that a discussion with the specified [discussionTitle] is present in the specified [groupName] group. + * + * @param groupName The name of the group containing the discussion. + * @param discussionTitle The title of the discussion to be asserted. + */ fun assertDiscussionInGroup(groupName: String, discussionTitle: String) { val groupChildMatcher = withId(R.id.groupName) + withText(groupName) waitForView(withId(R.id.discussionTitle) + withText(discussionTitle) + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAnnouncementPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAnnouncementPage.kt index 0ba16fa1ff..2bb5119eda 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAnnouncementPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAnnouncementPage.kt @@ -26,20 +26,37 @@ import com.instructure.espresso.replaceText import com.instructure.espresso.scrollTo import com.instructure.teacher.R +/** + * Represents the Edit Announcement page. + */ class EditAnnouncementPage : BasePage() { + /** + * Opens the edit mode for the announcement. + */ fun openEdit() { waitForView(withId(R.id.menu_edit)).click() } + /** + * Edits the name of the announcement with the specified [newName]. + * + * @param newName The new name for the announcement. + */ fun editAnnouncementName(newName: String) { onView(withId(R.id.announcementNameEditText)).replaceText(newName) } + /** + * Saves the edited announcement. + */ fun saveEditAnnouncement() { onView(withId(R.id.menuSaveAnnouncement)).click() } + /** + * Deletes the announcement. + */ fun deleteAnnouncement() { Espresso.closeSoftKeyboard() onView(withId(R.id.deleteAnnouncementButton)).scrollTo() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAssignmentDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAssignmentDetailsPage.kt index a8dbb7bba7..68f3fca888 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAssignmentDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAssignmentDetailsPage.kt @@ -27,8 +27,23 @@ import com.instructure.canvas.espresso.has import com.instructure.canvas.espresso.hasTextInputLayoutErrorText import com.instructure.canvas.espresso.withIndex import com.instructure.canvasapi2.utils.DateHelper -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertHasText +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.onViewWithId +import com.instructure.espresso.page.scrollTo +import com.instructure.espresso.page.waitForViewWithClassName +import com.instructure.espresso.page.waitForViewWithContentDescription +import com.instructure.espresso.page.waitForViewWithId +import com.instructure.espresso.page.waitForViewWithText +import com.instructure.espresso.page.waitScrollClick +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText +import com.instructure.espresso.replaceText +import com.instructure.espresso.scrollTo import com.instructure.teacher.R import com.instructure.teacher.ui.utils.TypeInRCETextEditor import com.instructure.teacher.view.AssignmentOverrideView @@ -38,6 +53,9 @@ import java.text.DecimalFormat import java.text.SimpleDateFormat import java.util.* +/** + * Represents the Edit Assignment Details page. + */ class EditAssignmentDetailsPage : BasePage() { private val assignmentNameEditText by OnViewWithId(R.id.editAssignmentName) @@ -49,102 +67,219 @@ class EditAssignmentDetailsPage : BasePage() { private val overlayContainer by OnViewWithId(R.id.overrideContainer, autoAssert = false) private val contentRceView by WaitForViewWithId(R.id.rce_webView, autoAssert = false) + /** + * Saves the assignment after making changes. + */ fun saveAssignment() { saveButton.click() } + /** + * Clicks on the assignment name EditText field. + */ fun clickAssignmentNameEditText() { assignmentNameEditText.click() } + /** + * Clicks on the points possible EditText field. + */ fun clickPointsPossibleEditText() { scrollTo(R.id.editGradePoints) pointsPossibleEditText.click() } + /** + * Edits the name of the assignment with the specified [newName]. + * + * @param newName The new name for the assignment. + */ fun editAssignmentName(newName: String) { assignmentNameEditText.replaceText(newName) Espresso.closeSoftKeyboard() } + /** + * Edits the points possible for the assignment with the specified [newPoints]. + * + * @param newPoints The new points possible for the assignment. + */ fun editAssignmentPoints(newPoints: Double) { val df = DecimalFormat("#") pointsPossibleEditText.replaceText(df.format(newPoints)) Espresso.closeSoftKeyboard() } + /** + * Edits the assignees for the assignment. + */ fun editAssignees() = waitScrollClick(R.id.assignTo) + + /** + * Clicks on the edit due date field. + */ fun clickEditDueDate() = waitScrollClick(R.id.dueDate) + + /** + * Clicks on the edit due time field. + */ fun clickEditDueTime() = waitScrollClick(R.id.dueTime) + + /** + * Clicks on the edit unlock date field. + */ fun clickEditUnlockDate() = waitScrollClick(R.id.fromDate) + + /** + * Clicks on the edit unlock time field. + */ fun clickEditUnlockTime() = waitScrollClick(R.id.fromTime) + + /** + * Clicks on the edit lock date field. + */ fun clickEditLockDate() = waitScrollClick(R.id.toDate) + + /** + * Clicks on the edit lock time field. + */ fun clickEditLockTime() = waitScrollClick(R.id.toTime) + + /** + * Clicks on the publish switch to toggle its state. + */ fun clickPublishSwitch() = waitScrollClick(R.id.publishSwitch) - fun clickAddOverride() = onView(allOf(withId(R.id.addOverride), withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))).scrollTo().click() + /** + * Clicks on the add override button. + */ + fun clickAddOverride() = + onView(allOf(withId(R.id.addOverride), withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))).scrollTo().click() + + /** + * Removes the first override from the list. + */ fun removeFirstOverride() { waitForViewWithContentDescription("remove_override_button_0").scrollTo().click() waitForViewWithText(R.string.remove).click() } + /** + * Edits the date with the specified [year], [month], and [dayOfMonth]. + * + * @param year The year. + * @param month The month (0-11). + * @param dayOfMonth The day of the month (1-31). + */ fun editDate(year: Int, month: Int, dayOfMonth: Int) { waitForViewWithClassName(Matchers.equalTo(DatePicker::class.java.name)) - .perform(PickerActions.setDate(year, month, dayOfMonth)) + .perform(PickerActions.setDate(year, month, dayOfMonth)) onViewWithId(android.R.id.button1).click() } + /** + * Edits the time with the specified [hour] and [min]. + * + * @param hour The hour (0-23). + * @param min The minute (0-59). + */ fun editTime(hour: Int, min: Int) { waitForViewWithClassName(Matchers.equalTo(TimePicker::class.java.name)) - .perform(PickerActions.setTime(hour, min)) + .perform(PickerActions.setTime(hour, min)) onViewWithId(android.R.id.button1).click() } + /** + * Asserts that the date with the specified [year], [month], and [dayOfMonth] has been changed. + * + * @param year The expected year. + * @param month The expected month (0-11). + * @param dayOfMonth The expected day of the month (1-31). + * @param id The resource ID of the view to assert the date change. + */ fun assertDateChanged(year: Int, month: Int, dayOfMonth: Int, id: Int) { - val cal = Calendar.getInstance().apply {set(year, month, dayOfMonth)} + val cal = Calendar.getInstance().apply { set(year, month, dayOfMonth) } waitForViewWithId(id).assertHasText(DateHelper.fullMonthNoLeadingZeroDateFormat.format(cal.time)) } + /** + * Asserts that the time with the specified [hour] and [min] has been changed. + * + * @param hour The expected hour (0-23). + * @param min The expected minute (0-59). + * @param id The resource ID of the view to assert the time change. + */ fun assertTimeChanged(hour: Int, min: Int, id: Int) { - val cal = Calendar.getInstance().apply {set(0, 0, 0, hour, min)} + val cal = Calendar.getInstance().apply { set(0, 0, 0, hour, min) } val sdh = SimpleDateFormat("H:mm a", Locale.US) waitForViewWithId(id).assertHasText(sdh.format(cal.time)) } + /** + * Asserts that a new override has been created. + */ fun assertNewOverrideCreated() { waitForViewWithId(R.id.overrideContainer).check(has(2, Matchers.instanceOf(AssignmentOverrideView::class.java))) } + /** + * Asserts that an override has been removed. + */ fun assertOverrideRemoved() { waitForViewWithId(R.id.overrideContainer).check(has(1, Matchers.instanceOf(AssignmentOverrideView::class.java))) } + /** + * Asserts that an error indicating that the due date is before the unlock date is shown. + */ fun assertDueDateBeforeUnlockDateErrorShown() { waitForViewWithId(R.id.fromDateTextInput).check(matches(hasTextInputLayoutErrorText(R.string.unlock_after_due_date_error))) } + /** + * Asserts that an error indicating that the due date is after the lock date is shown. + */ fun assertDueDateAfterLockDateErrorShown() { waitForViewWithId(R.id.toDateTextInput).check(matches(hasTextInputLayoutErrorText(R.string.lock_before_due_date_error))) } + /** + * Asserts that an error indicating that the lock date is after the unlock date is shown. + */ fun assertLockDateAfterUnlockDateErrorShown() { waitForViewWithId(R.id.toDateTextInput).check(matches(hasTextInputLayoutErrorText(R.string.lock_after_unlock_error))) } + /** + * Asserts that an error indicating that no assignees are selected is shown. + */ fun assertNoAssigneesErrorShown() { onView(withIndex(withId(R.id.assignToTextInput), 1)).check(matches(hasTextInputLayoutErrorText(R.string.assignee_blank_error))) } + /** + * Clicks on the display grade as spinner. + */ fun clickOnDisplayGradeAsSpinner() { onView(withId(R.id.displayGradeAsSpinner)).scrollTo().click() } + /** + * Selects the grade type with the specified [gradeType]. + * + * @param gradeType The grade type to select. + */ fun selectGradeType(gradeType: String) { onView(withText(gradeType)).click() } + /** + * Edits the description of the assignment with the specified [newDescription]. + * + * @param newDescription The new description for the assignment. + */ fun editDescription(newDescription: String) { contentRceView.perform(TypeInRCETextEditor(newDescription)) } - } + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDashboardPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDashboardPage.kt index 51518bd3c5..ba26777b0f 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDashboardPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDashboardPage.kt @@ -26,44 +26,99 @@ import com.instructure.espresso.* import com.instructure.espresso.page.* import com.instructure.teacher.R +/** + * A page representing the Edit Dashboard screen in the application. + */ @Suppress("unused") class EditDashboardPage : BasePage() { + /** + * The button for favoriting a course. + */ private val favouriteButton by WaitForViewWithId(R.id.favoriteButton) + + /** + * The label for the "All Courses" section. + */ private val allCoursesLabel by WaitForViewWithText(R.string.allCourses) + /** + * Asserts that the Edit Dashboard screen displays the given list of courses. + * + * @param mCourses The list of courses to verify. + */ fun assertHasCourses(mCourses: List) { - // Check that the recyclerview count matches course count onView(withId(R.id.recyclerView)).check(RecyclerViewItemCountAssertion(mCourses.size)) - for (course in mCourses) Espresso.onView(ViewMatchers.withText(course.name)).assertDisplayed() + for (course in mCourses) { + Espresso.onView(ViewMatchers.withText(course.name)).assertDisplayed() + } } + /** + * Asserts that the Edit Dashboard screen displays a specific course. + * + * @param courseName The name of the course to verify. + */ fun assertHasCourse(courseName: String) { onView(withText(courseName) + withAncestor(withId(R.id.recyclerView))).assertDisplayed() } + /** + * Asserts that a specific course is favored in the Edit Dashboard screen. + * + * @param course The course to verify. + */ fun assertCourseFavoured(course: Course) { onView(withId(R.id.favoriteButton) + hasSibling(withId(R.id.title) + withText(course.name))).check( - matches(withContentDescription(R.string.a11y_content_description_remove_from_dashboard))) + matches(withContentDescription(R.string.a11y_content_description_remove_from_dashboard)) + ) } + /** + * Asserts that a specific course is not favored in the Edit Dashboard screen. + * + * @param course The course to verify. + */ fun assertCourseUnfavoured(course: Course) { onView(withId(R.id.favoriteButton) + hasSibling(withId(R.id.title) + withText(course.name))).check( - matches(withContentDescription(R.string.a11y_content_description_add_to_dashboard))) + matches(withContentDescription(R.string.a11y_content_description_add_to_dashboard)) + ) } + /** + * Toggles favoring/unfavoring a specific course in the Edit Dashboard screen. + * + * @param courseName The name of the course to toggle favoring. + */ fun toggleFavouringCourse(courseName: String) { onView(withId(R.id.favoriteButton) + hasSibling(withId(R.id.title) + withText(courseName))).click() } + /** + * Asserts that the mass select button is displayed with the appropriate label based on whether + * some items are selected or not. + * + * @param someSelected Indicates whether some items are selected or not. + */ fun assertMassSelectButtonIsDisplayed(someSelected: Boolean) { - if (someSelected) onView(withText(R.string.unselect_all)).assertDisplayed() - else onView(withText(R.string.select_all)).assertDisplayed() + if (someSelected) { + onView(withText(R.string.unselect_all)).assertDisplayed() + } else { + onView(withText(R.string.select_all)).assertDisplayed() + } } + /** + * Clicks on the mass select button in the Edit Dashboard screen based on the selection state. + * + * @param someSelected Indicates whether some items are selected or not. + */ fun clickOnMassSelectButton(someSelected: Boolean) { assertMassSelectButtonIsDisplayed(someSelected) - if (someSelected) onView(withText(R.string.unselect_all) + withAncestor(R.id.selectButton)).click() - else onView(withText(R.string.select_all) + withAncestor(R.id.selectButton)).click() + if (someSelected) { + onView(withText(R.string.unselect_all) + withAncestor(R.id.selectButton)).click() + } else { + onView(withText(R.string.select_all) + withAncestor(R.id.selectButton)).click() + } } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDiscussionsDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDiscussionsDetailsPage.kt index d4d6000b08..fa67b64638 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDiscussionsDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDiscussionsDetailsPage.kt @@ -27,34 +27,61 @@ import com.instructure.espresso.scrollTo import com.instructure.teacher.R import com.instructure.teacher.ui.utils.TypeInRCETextEditor +/** + * The `EditDiscussionsDetailsPage` class represents a page for editing discussion details. + * It extends the `BasePage` class. + * + * @constructor Creates an instance of `EditDiscussionsDetailsPage`. + */ class EditDiscussionsDetailsPage : BasePage() { private val contentRceView by WaitForViewWithId(R.id.rce_webView) + /** + * Edits the title of the discussion. + * + * @param newTitle The new title of the discussion. + */ fun editTitle(newTitle: String) { onView(withId(R.id.editDiscussionName)).replaceText(newTitle) Espresso.closeSoftKeyboard() } + /** + * Toggles the published state of the discussion. + */ fun togglePublished() { onView(withId(R.id.publishSwitch)).scrollTo().click() } + /** + * Deletes the discussion. + */ fun deleteDiscussion() { onView(withId(R.id.deleteText)).scrollTo().click() - onView(withId(android.R.id.button1)).click() //button1 is actually the 'DELETE' button on the UI pop-up dialog. + onView(withId(android.R.id.button1)).click() // button1 is actually the 'DELETE' button on the UI pop-up dialog. } - fun clickSave() { //This method is used when editing an existing discussion. + /** + * Clicks the save button. This method is used when editing an existing discussion. + */ + fun clickSave() { onView(withId(R.id.menuSave)).click() } - fun clickSendNewDiscussion() { //This method is used when creating a new discussion via mobile UI. + /** + * Clicks the send new discussion button. This method is used when creating a new discussion via mobile UI. + */ + fun clickSendNewDiscussion() { onView(withId(R.id.menuSaveDiscussion)).click() } + /** + * Edits the description of the discussion. + * + * @param newDescription The new description of the discussion. + */ fun editDescription(newDescription: String) { contentRceView.perform(TypeInRCETextEditor(newDescription)) } - -} \ No newline at end of file +} diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditPageDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditPageDetailsPage.kt index e71a2f2431..b68f2d69f9 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditPageDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditPageDetailsPage.kt @@ -9,63 +9,103 @@ import androidx.test.espresso.web.webdriver.DriverAtoms.getText import androidx.test.espresso.web.webdriver.Locator import com.instructure.canvas.espresso.checkToastText import com.instructure.canvas.espresso.withElementRepeat -import com.instructure.espresso.* +import com.instructure.espresso.ActivityHelper +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView +import com.instructure.espresso.replaceText +import com.instructure.espresso.scrollTo import com.instructure.teacher.R import com.instructure.teacher.ui.utils.TypeInRCETextEditor import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.containsString +/** + * The `EditPageDetailsPage` class represents a page for editing page details. + * It extends the `BasePage` class. + * + * @constructor Creates an instance of `EditPageDetailsPage`. + */ class EditPageDetailsPage : BasePage() { private val contentRceView by WaitForViewWithId(R.id.rce_webView) - fun runTextChecks(vararg checks : WebViewTextCheck) { - for(check in checks) { - if(check.repeatSecs != null) { + /** + * Runs text checks on the web view. + * + * @param checks The array of `WebViewTextCheck` objects representing the text checks to perform. + */ + fun runTextChecks(vararg checks: WebViewTextCheck) { + for (check in checks) { + if (check.repeatSecs != null) { onWebView(allOf(withId(R.id.contentWebView), isDisplayed())) - .withElementRepeat(findElement(check.locatorType, check.locatorValue), check.repeatSecs) - .check(webMatches(getText(), containsString(check.textValue))) - } - else { + .withElementRepeat(findElement(check.locatorType, check.locatorValue), check.repeatSecs) + .check(webMatches(getText(), containsString(check.textValue))) + } else { onWebView(allOf(withId(R.id.contentWebView), isDisplayed())) - .withElement(findElement(check.locatorType, check.locatorValue)) - .check(webMatches(getText(), containsString(check.textValue))) + .withElement(findElement(check.locatorType, check.locatorValue)) + .check(webMatches(getText(), containsString(check.textValue))) } } } + /** + * Opens the edit page. + */ fun openEdit() { onView(withId(R.id.menu_edit)).click() } + /** + * Saves the page. + */ fun savePage() { onView(withId(R.id.menuSavePage)).click() } + /** + * Toggles the front page. + */ fun toggleFrontPage() { onView(withId(R.id.frontPageSwitchWrapper)).scrollTo() onView(withId(R.id.frontPageSwitch)).click() } + /** + * Toggles the published state of the page. + */ fun togglePublished() { onView(withId(R.id.publishWrapper)).scrollTo() onView(withId(R.id.publishSwitch)).click() } + /** + * Edits the name of the page. + * + * @param editedPageName The edited name of the page. + */ fun editPageName(editedPageName: String) { onView(withId(R.id.pageNameEditText)).replaceText(editedPageName) } + /** + * Edits the description of the page. + * + * @param newDescription The new description of the page. + */ fun editDescription(newDescription: String) { contentRceView.perform(TypeInRCETextEditor(newDescription)) } + /** + * Displays a toast message indicating that saving an unpublished front page is not possible. + */ fun unableToSaveUnpublishedFrontPage() { savePage() checkToastText(R.string.frontPageUnpublishedError, ActivityHelper.currentActivity()) } } + data class WebViewTextCheck( val locatorType: Locator, val locatorValue: String, diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditProfileSettingsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditProfileSettingsPage.kt index 10d54be650..2176737592 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditProfileSettingsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditProfileSettingsPage.kt @@ -25,6 +25,12 @@ import com.instructure.espresso.page.withId import com.instructure.espresso.typeText import com.instructure.teacher.R +/** + * The `EditProfileSettingsPage` class represents a page for editing profile settings. + * It extends the `BasePage` class. + * + * @constructor Creates an instance of `EditProfileSettingsPage`. + */ class EditProfileSettingsPage : BasePage(R.id.editProfileSettingsPage) { private val toolbar by OnViewWithId(R.id.toolbar) @@ -33,19 +39,28 @@ class EditProfileSettingsPage : BasePage(R.id.editProfileSettingsPage) { private val usersName by OnViewWithId(R.id.usersName) private val profileCameraIcon by OnViewWithId(R.id.profileCameraIcon) + /** + * Clicks on the save button to save the profile settings. + */ fun clickOnSave() { onView(withId(R.id.menuSave)).click() } + /** + * Edits the user name with the specified new user name. + * + * @param newUserName The new user name to be set. + */ fun editUserName(newUserName: String) { clearUserNameInputField() usersName.typeText(newUserName) } - fun clearUserNameInputField() { + /** + * Clears the user name input field. + */ + private fun clearUserNameInputField() { usersName.clearText() } - - - } + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditQuizDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditQuizDetailsPage.kt index c6352970dd..eb377c3fbe 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditQuizDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditQuizDetailsPage.kt @@ -17,18 +17,38 @@ package com.instructure.teacher.ui.pages +import android.widget.DatePicker +import android.widget.TimePicker import androidx.test.espresso.Espresso import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.contrib.PickerActions -import androidx.test.espresso.matcher.ViewMatchers.* -import android.widget.DatePicker -import android.widget.TimePicker -import com.instructure.canvasapi2.utils.DateHelper -import com.instructure.espresso.* +import androidx.test.espresso.matcher.ViewMatchers.Visibility +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import com.instructure.canvas.espresso.has import com.instructure.canvas.espresso.hasTextInputLayoutErrorText import com.instructure.canvas.espresso.withIndex -import com.instructure.espresso.page.* +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.espresso.ClickUntilMethod +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertHasText +import com.instructure.espresso.assertVisible +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.onViewWithContentDescription +import com.instructure.espresso.page.onViewWithId +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.waitForViewWithClassName +import com.instructure.espresso.page.waitForViewWithId +import com.instructure.espresso.page.waitForViewWithText +import com.instructure.espresso.page.waitScrollClick +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText +import com.instructure.espresso.randomString +import com.instructure.espresso.replaceText +import com.instructure.espresso.scrollTo import com.instructure.teacher.R import com.instructure.teacher.view.AssignmentOverrideView import org.hamcrest.CoreMatchers.allOf @@ -36,6 +56,10 @@ import org.hamcrest.Matchers import java.text.SimpleDateFormat import java.util.* +/** + * The `EditQuizDetailsPage` class represents a page for editing quiz details. + * It extends the `BasePage` class. + */ class EditQuizDetailsPage : BasePage() { private val quizTitleEditText by OnViewWithId(R.id.editQuizTitle) @@ -44,30 +68,49 @@ class EditQuizDetailsPage : BasePage() { private val accessCodeEditText by WaitForViewWithId(R.id.editAccessCode) private val saveButton by OnViewWithId(R.id.menuSave) private val descriptionWebView by OnViewWithId(R.id.descriptionWebView, autoAssert = false) - private val noDescriptionTextView by OnViewWithId(R.id.noDescriptionTextView, autoAssert = false) + private val noDescriptionTextView by OnViewWithId( + R.id.noDescriptionTextView, + autoAssert = false + ) + /** + * Saves the quiz by clicking the save button. + */ fun saveQuiz() { saveButton.click() } + /** + * Edits the quiz title with the specified new name. + * + * @param newName The new name to be set as the quiz title. + */ fun editQuizTitle(newName: String) { - // Combination of scroll and click randomly selects the text sometimes. - // This opens a contextual menu and blocks the background view to receive focus and fails the test. - // That is why, `quizTitleEditText.scrollTo()` is not needed. quizTitleEditText.replaceText(newName) saveQuiz() } + /** + * Clicks on the access code switch to toggle its state. + */ fun clickAccessCode() { accessCodeSwitch.scrollTo() accessCodeSwitch.click() } + /** + * Clicks on the access code edit text field. + */ fun clickAccessCodeEditText() { accessCodeEditText.scrollTo() accessCodeEditText.click() } + /** + * Edits the access code for the quiz and saves it. + * + * @return The new access code. + */ fun editAccessCode(): String { val code = randomString() accessCodeEditText.scrollTo() @@ -76,70 +119,142 @@ class EditQuizDetailsPage : BasePage() { return code } + /** + * Edits the date of the quiz with the specified year, month, and day. + * + * @param year The year value. + * @param month The month value (0-11). + * @param dayOfMonth The day of the month value. + */ fun editDate(year: Int, month: Int, dayOfMonth: Int) { waitForViewWithClassName(Matchers.equalTo(DatePicker::class.java.name)) - .perform(PickerActions.setDate(year, month, dayOfMonth)) + .perform(PickerActions.setDate(year, month, dayOfMonth)) onViewWithId(android.R.id.button1).click() } + /** + * Edits the time of the quiz with the specified hour and minute. + * + * @param hour The hour value. + * @param min The minute value. + */ fun editTime(hour: Int, min: Int) { waitForViewWithClassName(Matchers.equalTo(TimePicker::class.java.name)) - .perform(PickerActions.setTime(hour, min)) + .perform(PickerActions.setTime(hour, min)) onViewWithId(android.R.id.button1).click() } + /** + * Removes the second override for the quiz. + */ fun removeSecondOverride() { - // scroll to bottom to make the 2nd override button visible addOverrideButton().scrollTo() - onViewWithContentDescription("remove_override_button_1").scrollTo() ClickUntilMethod.run( - onView(withContentDescription("remove_override_button_1")), - onView(withText("Remove Due Date")) + onView(withContentDescription("remove_override_button_1")), + onView(withText("Remove Due Date")) ) - - // Wait for alert dialog to display before clicking "Remove" waitForViewWithText(R.string.removeDueDate).assertVisible() waitForViewWithText(R.string.remove).click() } + /** + * Asserts that the date has changed to the specified year, month, and day. + * + * @param year The expected year value. + * @param month The expected month value (0-11). + * * @param dayOfMonth The expected day of the month value. + * @param id The resource ID of the view displaying the date. + */ fun assertDateChanged(year: Int, month: Int, dayOfMonth: Int, id: Int) { val cal = Calendar.getInstance().apply { set(year, month, dayOfMonth) } waitForViewWithId(id).assertHasText(DateHelper.fullMonthNoLeadingZeroDateFormat.format(cal.time)) } + /** + * Asserts that the time has changed to the specified hour and minute. + * + * @param hour The expected hour value. + * @param min The expected minute value. + * @param id The resource ID of the view displaying the time. + */ fun assertTimeChanged(hour: Int, min: Int, id: Int) { val cal = Calendar.getInstance().apply { set(0, 0, 0, hour, min) } val sdh = SimpleDateFormat("H:mm a", Locale.US) waitForViewWithId(id).assertHasText(sdh.format(cal.time)) } + /** + * Asserts that a new override has been created for the quiz. + */ fun assertNewOverrideCreated() { - waitForViewWithId(R.id.overrideContainer).check(has(2, Matchers.instanceOf(AssignmentOverrideView::class.java))) + waitForViewWithId(R.id.overrideContainer).check( + has( + 2, + Matchers.instanceOf(AssignmentOverrideView::class.java) + ) + ) } + /** + * Asserts that an override has been removed from the quiz. + */ fun assertOverrideRemoved() { - waitForViewWithId(R.id.overrideContainer).check(has(1, Matchers.instanceOf(AssignmentOverrideView::class.java))) + waitForViewWithId(R.id.overrideContainer).check( + has( + 1, + Matchers.instanceOf(AssignmentOverrideView::class.java) + ) + ) } + /** + * Asserts that the "Due Date Before Unlock Date" error message is shown. + */ fun assertDueDateBeforeUnlockDateErrorShown() { - waitForViewWithId(R.id.fromDateTextInput).check(ViewAssertions.matches(hasTextInputLayoutErrorText(R.string.unlock_after_due_date_error))) + waitForViewWithId(R.id.fromDateTextInput).check( + ViewAssertions.matches( + hasTextInputLayoutErrorText(R.string.unlock_after_due_date_error) + ) + ) } + /** + * Asserts that the "Due Date After Lock Date" error message is shown. + */ fun assertDueDateAfterLockDateErrorShown() { - waitForViewWithId(R.id.toDateTextInput).check(ViewAssertions.matches(hasTextInputLayoutErrorText(R.string.lock_before_due_date_error))) + waitForViewWithId(R.id.toDateTextInput).check( + ViewAssertions.matches( + hasTextInputLayoutErrorText(R.string.lock_before_due_date_error) + ) + ) } + /** + * Asserts that the "Lock Date After Unlock Date" error message is shown. + */ fun assertLockDateAfterUnlockDateErrorShown() { - waitForViewWithId(R.id.toDateTextInput).check(ViewAssertions.matches(hasTextInputLayoutErrorText(R.string.lock_after_unlock_error))) + waitForViewWithId(R.id.toDateTextInput).check( + ViewAssertions.matches( + hasTextInputLayoutErrorText(R.string.lock_after_unlock_error) + ) + ) } + /** + * Asserts that the "No Assignees" error message is shown. + */ fun assertNoAssigneesErrorShown() { - Espresso.onView(withIndex(withId(R.id.assignToTextInput), 1)).check(ViewAssertions.matches(hasTextInputLayoutErrorText(R.string.assignee_blank_error))) + Espresso.onView(withIndex(withId(R.id.assignToTextInput), 1)) + .check(ViewAssertions.matches(hasTextInputLayoutErrorText(R.string.assignee_blank_error))) } - private fun addOverrideButton() = waitForView(allOf(withId(R.id.addOverride), - withEffectiveVisibility(Visibility.VISIBLE))) + private fun addOverrideButton() = waitForView( + allOf( + withId(R.id.addOverride), + withEffectiveVisibility(Visibility.VISIBLE) + ) + ) fun editAssignees() = waitScrollClick(R.id.assignTo) fun clickEditDueDate() = waitScrollClick(R.id.dueDate) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditSyllabusPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditSyllabusPage.kt index 44c32a6406..0723ba0e80 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditSyllabusPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditSyllabusPage.kt @@ -19,11 +19,32 @@ package com.instructure.teacher.ui.pages import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast import com.instructure.canvas.espresso.withCustomConstraints -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.OnViewWithText +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.WaitForViewWithText +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.withId +import com.instructure.espresso.scrollTo import com.instructure.teacher.R import com.instructure.teacher.ui.utils.TypeInRCETextEditor +/** + * Represents the Edit Syllabus page. + * + * This page extends the BasePage class and provides functionality for editing the syllabus. + * It contains various view elements such as a toolbar, save button, content RCE view, + * and toggles for showing the course summary. + * + * @property toolbar The toolbar view displaying the title of the page. + * @property saveButton The save button view for saving the syllabus edit. + * @property contentRceView The content RCE view used for editing the syllabus body. + * @property editSyllabusShowCourseSummarySwitch The switch view for toggling the display of the course summary. + * @property editSyllabusShowCourseSummaryLabel The label view for the course summary toggle. + */ class EditSyllabusPage : BasePage(R.id.editSyllabusPage) { private val toolbar by WaitForViewWithText(R.string.editSyllabusTitle) @@ -32,20 +53,34 @@ class EditSyllabusPage : BasePage(R.id.editSyllabusPage) { private val editSyllabusShowCourseSummarySwitch by OnViewWithText(R.id.showSummarySwitch) private val editSyllabusShowCourseSummaryLabel by OnViewWithId(R.id.showSummaryLabel) + /** + * Asserts that the toolbar is displayed with the correct title. + */ fun assertToolbarDisplayedWithCorrectTitle() { toolbar.assertDisplayed() } + /** + * Edits the syllabus body with the specified text. + * + * @param text The text to be entered in the syllabus body. + */ fun editSyllabusBody(text: String) { contentRceView.perform(TypeInRCETextEditor(text)) } + /** + * Saves the syllabus edit by clicking the save button. + */ fun saveSyllabusEdit() { saveButton.click() } + /** + * Toggles the display of the course summary. + */ fun editSyllabusToggleShowSummary() { editSyllabusShowCourseSummaryLabel.scrollTo() onView(withId(R.id.showSummarySwitch)).perform(withCustomConstraints(click(), isDisplayingAtLeast(50))) } -} \ No newline at end of file +} diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/FileListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/FileListPage.kt index ce4ffee375..49b03d3e4a 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/FileListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/FileListPage.kt @@ -43,39 +43,78 @@ import com.instructure.espresso.typeText import com.instructure.teacher.R import org.hamcrest.Matchers.allOf +/** + * Represents the File List page. + * + * This page extends the BasePage class and provides functionality for managing files. + * It contains various view elements such as buttons for adding files and folders, + * and methods for asserting the presence or absence of items, selecting items, + * refreshing the view, renaming and deleting files, creating and deleting folders, + * performing search operations, and asserting the count of search results and file lists. + * + * @constructor Creates an instance of the FileListPage class. + */ class FileListPage : BasePage(R.id.fileListPage) { private val addButton by OnViewWithId(R.id.addFab) private val uploadFileButton by OnViewWithId(R.id.addFileFab, autoAssert = false) private val newFolderButton by OnViewWithId(R.id.addFolderFab, autoAssert = false) + /** + * Asserts that the specified item is displayed in the file list. + * + * @param itemName The name of the item to assert. + */ fun assertItemDisplayed(itemName: String) { val matcher = allOf(withId(R.id.fileName), withText(itemName)) waitForView(matcher).scrollTo().assertDisplayed() } + /** + * Asserts that the specified item is not displayed in the file list. + * + * @param itemName The name of the item to assert. + */ fun assertItemNotDisplayed(itemName: String) { val matcher = allOf(withId(R.id.fileName), withText(itemName)) onView(matcher).check(doesNotExist()) } + /** + * Selects the specified item in the file list. + * + * @param itemName The name of the item to select. + */ fun selectItem(itemName: String) { val matcher = allOf(withId(R.id.fileName), withText(itemName)) scrollRecyclerView(R.id.fileListRecyclerView, matcher) onView(matcher).click() } + /** + * Refreshes the file list by performing a swipe-down gesture. + */ fun refresh() { onView(allOf(withId(R.id.swipeRefreshLayout), isDisplayingAtLeast(50))) - .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) + .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) } + /** + * Opens the option menu for the specified item. + * + * @param itemName The name of the item to open the option menu for. + */ private fun openOptionMenuFor(itemName: String) { openActionBarOverflowOrOptionsMenu(getInstrumentation().getTargetContext()); onView(withText(itemName)) .perform(click()); } + /** + * Renames the currently selected file with the specified new name. + * + * @param newName The new name for the file. + */ fun renameFile(newName: String) { openOptionMenuFor("Edit") onView(withId(R.id.titleEditText)).clearText() @@ -84,6 +123,11 @@ class FileListPage : BasePage(R.id.fileListPage) { .perform(click()); } + /** + * Deletes the specified file. + * + * @param itemName The name of the file to delete. + */ fun deleteFile(itemName: String) { selectItem(itemName) openOptionMenuFor("Edit") @@ -92,11 +136,18 @@ class FileListPage : BasePage(R.id.fileListPage) { onView(allOf(isAssignableFrom(AppCompatButton::class.java), containsTextCaseInsensitive("DELETE"), isDisplayed())).click() } - + /** + *Asserts that the view is empty. + */ fun assertViewEmpty() { waitForView(allOf(withId(R.id.emptyPandaView), isDisplayed())).assertDisplayed() } + /** + * Creates a folder with the specified folder name. + * + * @param folderName The name of the folder to create. + */ fun createFolder(folderName: String) { onView(withId(R.id.addFab)).click() waitForView(withId(R.id.addFolderFab)).click() @@ -105,6 +156,11 @@ class FileListPage : BasePage(R.id.fileListPage) { onView(withText(R.string.ok)).click() } + /** + * Deletes the specified folder. + * + * @param folderName The name of the folder to delete. + */ fun deleteFolder(folderName: String) { selectItem(folderName) onView(withId(R.id.edit)).click() @@ -113,19 +169,35 @@ class FileListPage : BasePage(R.id.fileListPage) { onView(allOf(isAssignableFrom(AppCompatButton::class.java), containsTextCaseInsensitive("DELETE"), isDisplayed())).click() } + /** + * Clicks the search button. + */ fun clickSearchButton() { onView(withId(R.id.search)).click() } + /** + * Types the specified search text into the search input field. + * + * @param searchText The text to be typed in the search input field. + */ fun typeSearchInput(searchText: String) { onView(withId(R.id.queryInput)).replaceText(searchText) } + /** + * Clicks the reset search text button. + */ fun clickResetSearchText() { waitForView(withId(R.id.clearButton)).click() onView(withId(R.id.backButton)).click() } + /** + * Asserts the count of search results matches the expected count. + * + * @param expectedCount The expected count of search results. + */ fun assertSearchResultCount(expectedCount: Int) { Thread.sleep(2000) onView(withId(R.id.fileSearchRecyclerView) + withAncestor(R.id.container)).check( @@ -133,6 +205,11 @@ class FileListPage : BasePage(R.id.fileListPage) { ) } + /** + * Asserts the count of file lists matches the expected count. + * + * @param expectedCount The expected count of file lists. + */ fun assertFileListCount(expectedCount: Int) { Thread.sleep(2000) onView(withId(R.id.fileListRecyclerView) + withAncestor(R.id.container)).check( @@ -140,7 +217,10 @@ class FileListPage : BasePage(R.id.fileListPage) { ) } + /** + * Presses the back button in the search view. + */ fun pressSearchBackButton() { onView(withId(R.id.backButton)).click() } -} \ No newline at end of file +} diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/HelpPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/HelpPage.kt index 0da18be26d..8e1ac9aff4 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/HelpPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/HelpPage.kt @@ -25,57 +25,110 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.canvasapi2.models.Course -import com.instructure.espresso.* +import com.instructure.espresso.OnViewWithStringTextIgnoreCase +import com.instructure.espresso.OnViewWithText +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.click import com.instructure.espresso.matchers.WaitForViewMatcher.waitForView import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.plus +import com.instructure.espresso.scrollTo +import com.instructure.espresso.typeText import com.instructure.teacher.R +/** + * A page representing the Help menu in the application. + * + */ class HelpPage : BasePage(R.id.helpDialog) { + + /** + * The label for asking an instructor. + */ private val askInstructorLabel by OnViewWithText(R.string.askInstructor) + + /** + * The label for searching guides. + */ private val searchGuidesLabel by OnViewWithText(R.string.searchGuides) + + /** + * The label for reporting a problem. + */ private val reportProblemLabel by OnViewWithText(R.string.reportProblem) + + /** + * The label for submitting a feature idea. + */ private val submitFeatureLabel by OnViewWithStringTextIgnoreCase("Submit a Feature Idea") + + /** + * The label for sharing your love. + */ private val shareLoveLabel by OnViewWithText(R.string.shareYourLove) + /** + * Verifies asking a question to an instructor. + * + * @param course The course to select in the spinner. + * @param question The question to type in the message field. + */ fun verifyAskAQuestion(course: Course, question: String) { askInstructorLabel.scrollTo().click() - waitForView(withText(course.name)).assertDisplayed() // Verify that our course is selected in the spinner + waitForView(withText(course.name)).assertDisplayed() onView(withId(R.id.message)).scrollTo().perform(withCustomConstraints(typeText(question), isDisplayingAtLeast(1))) Espresso.closeSoftKeyboard() - // Let's just make sure that the "Send" button is displayed, rather than actually pressing it onView(containsTextCaseInsensitive("Send")).assertDisplayed() } + /** + * Launches the guides page. + */ fun launchGuides() { searchGuidesLabel.scrollTo().click() } + /** + * Verifies reporting a problem. + * + * @param subject The subject of the problem. + * @param description The description of the problem. + */ fun verifyReportAProblem(subject: String, description: String) { reportProblemLabel.scrollTo().click() onView(withId(R.id.subjectEditText)).typeText(subject) Espresso.closeSoftKeyboard() onView(withId(R.id.descriptionEditText)).typeText(description) Espresso.closeSoftKeyboard() - // Let's just make sure that the "Send" button is displayed, rather than actually pressing it onView(containsTextCaseInsensitive("Send")).scrollTo().assertDisplayed() } + /** + * Launches the share your love page. + */ fun shareYourLove() { shareLoveLabel.scrollTo().click() } + /** + * Submits a feature idea. + */ fun submitFeature() { submitFeatureLabel.scrollTo().click() } + /** + * Asserts that the Help menu is displayed. + */ fun assertHelpMenuDisplayed() { onView(withId(R.id.alertTitle) + withText(R.string.help)).assertDisplayed() onView(withId(R.id.helpDialog)).assertDisplayed() } + /** + * Asserts the content of the Help menu. + */ fun assertHelpMenuContent() { - onView(withId(R.id.title) + withText(R.string.searchGuides)) onView(withId(R.id.subtitle) + withText(R.string.searchGuidesDetails)) @@ -89,7 +142,7 @@ class HelpPage : BasePage(R.id.helpDialog) { onView(withId(R.id.subtitle) + withText(R.string.shareYourLoveDetails)) onView(withId(R.id.title) + withText("Submit a Feature Idea")) - onView(withId(R.id.subtitle) + withText("Have an idea to improve Canvas?")) + onView(withId(R.id.subtitle) + withText("Have an idea to improveCanvas?")) onView(withId(R.id.title) + withText("COVID-19 Canvas Resources")) onView(withId(R.id.subtitle) + withText("Tips for teaching and learning online")) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/InboxMessagePage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/InboxMessagePage.kt index b84e0c2dd0..e0044eece7 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/InboxMessagePage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/InboxMessagePage.kt @@ -22,7 +22,12 @@ import androidx.test.espresso.action.ViewActions import androidx.test.espresso.matcher.ViewMatchers import androidx.test.platform.app.InstrumentationRegistry import com.instructure.canvas.espresso.containsTextCaseInsensitive -import com.instructure.espresso.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.OnViewWithMatcher +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.withId @@ -31,6 +36,13 @@ import com.instructure.teacher.R import org.hamcrest.CoreMatchers.allOf import org.hamcrest.Matchers +/** + * Represents the Inbox Message page. + * + * This page extends the BasePage class and provides functionality for interacting with an inbox message. + * It contains various view elements such as star image button, author name text view, subject text view, + * message recycler view, and reply text view. + */ class InboxMessagePage: BasePage() { private val starImageButton by OnViewWithId(R.id.starred) @@ -48,31 +60,51 @@ class InboxMessagePage: BasePage() { replyTextView.assertDisplayed() } + /** + * Asserts that the message page has at least one message. + */ fun assertHasMessage() { messageRecyclerView.check(RecyclerViewItemCountAssertion(1)) } + /** + * Clicks the reply button to compose a reply message. + */ fun clickReply() { replyTextView.click() } + /** + * Asserts that the message page has at least one reply. + */ fun assertHasReply() { messageRecyclerView.check(RecyclerViewItemCountAssertion(2)) } + /** + * Clicks on the star icon to mark the conversation as starred. + */ fun clickOnStarConversation() { onView(withId(R.id.starred)).click() } + /** + * Opens the option menu for the specified item. + * + * @param itemName The name of the item to open the option menu for. + */ fun openOptionMenuFor(itemName: String) { Espresso.openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getInstrumentation().getTargetContext()); Espresso.onView(ViewMatchers.withText(itemName)) .perform(ViewActions.click()); } + /** + * Deletes the conversation. + */ fun deleteConversation() { openOptionMenuFor("Delete") Espresso.onView(Matchers.allOf(ViewMatchers.isAssignableFrom(AppCompatButton::class.java), - containsTextCaseInsensitive("DELETE"))).click() + containsTextCaseInsensitive("DELETE"))).click() } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/InboxPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/InboxPage.kt index 31f0410e66..51a767489a 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/InboxPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/InboxPage.kt @@ -36,6 +36,13 @@ import com.instructure.teacher.R import com.instructure.teacher.ui.utils.WaitForToolbarTitle import org.hamcrest.Matchers +/** + * Represents the Inbox Page. + * + * This page extends the BasePage class and provides functionality for interacting with the inbox. + * It contains various view elements such as toolbar, inbox recycler view, add message FAB, + * empty inbox view, scope filter text, and edit toolbar. + */ class InboxPage: BasePage() { private val toolbarTitle by WaitForToolbarTitle(R.string.tab_inbox) @@ -53,64 +60,124 @@ class InboxPage: BasePage() { toolbarTitle.assertDisplayed() } + /** + * Asserts that the inbox has at least one conversation. + */ fun assertHasConversation() { assertConversationCountIsGreaterThan(0) } + /** + * Asserts that the count of conversations is greater than the specified count. + * + * @param count The count to compare against. + */ fun assertConversationCountIsGreaterThan(count: Int) { inboxRecyclerView.check(RecyclerViewItemCountGreaterThanAssertion(count)) } + /** + * Asserts that the count of conversations matches the specified count. + * + * @param count The expected count of conversations. + */ fun assertConversationCount(count: Int) { inboxRecyclerView.check(RecyclerViewItemCountAssertion(count)) } + /** + * Clicks on the conversation with the specified subject. + * + * @param conversationSubject The subject of the conversation to click. + */ + fun clickConversation(conversationSubject: String) { + waitForViewWithText(conversationSubject).click() + } + + /** + * Clicks on the conversation with the specified subject. + * + * @param conversation The subject of the conversation to click. + */ fun clickConversation(conversation: ConversationApiModel) { clickConversation(conversation.subject) } + /** + * Clicks on the conversation with the specified subject. + * + * @param conversation The subject of the conversation to click. + */ fun clickConversation(conversation: Conversation) { clickConversation(conversation.subject!!) } - fun clickConversation(conversationSubject: String) { - waitForViewWithText(conversationSubject).click() - } - + /** + * Clicks on the add message FAB. + */ fun clickAddMessageFAB() { addMessageFAB.click() } + /** + * Asserts that the inbox is empty. + */ fun assertInboxEmpty() { onView(withId(R.id.emptyInboxView)).assertDisplayed() } + /** + * Refreshes the inbox view. + */ fun refresh() { onView(withId(R.id.swipeRefreshLayout)) .perform(withCustomConstraints(ViewActions.swipeDown(), isDisplayingAtLeast(50))) } + /** + * Filters the messages by the specified filter. + * + * @param filterFor The filter to apply. + */ fun filterMessageScope(filterFor: String) { waitForView(withId(R.id.scopeFilterText)) onView(withId(R.id.scopeFilter)).click() waitForViewWithText(filterFor).click() } + /** + * Filters the messages by the specified course scope. + * + * @param courseName The name of the course to filter for. + */ fun filterCourseScope(courseName: String) { waitForView(withId(R.id.courseFilter)).click() waitForViewWithText(courseName).click() } + /** + * Clears the course filter. + */ fun clearCourseFilter() { waitForView(withId(R.id.courseFilter)).click() onView(withId(R.id.clear) + withText(R.string.inboxClearFilter)).click() } + /** + * Asserts whether there is an unread message based on the specified flag. + * + * @param unread Flag indicating whether there is an unread message. + */ fun assertThereIsAnUnreadMessage(unread: Boolean) { if(unread) onView(withId(R.id.unreadMark)).assertDisplayed() else onView(withId(R.id.unreadMark) + ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.GONE)) } + /** + * Asserts that the conversation with the specified subject is starred. + * + * @param subject The subject of the conversation. + */ fun assertConversationStarred(subject: String) { val matcher = Matchers.allOf( withId(R.id.star), @@ -123,6 +190,11 @@ class InboxPage: BasePage() { onView(matcher).assertDisplayed() } + /** + * Asserts that the conversation with the specified subject is not starred. + * + * @param subject The subject of the conversation. + */ fun assertConversationNotStarred(subject: String) { val matcher = Matchers.allOf( withId(R.id.star), @@ -133,19 +205,34 @@ class InboxPage: BasePage() { ) waitForMatcherWithRefreshes(matcher) // May need to refresh before the star shows up onView(matcher).check(ViewAssertions.doesNotExist()) - } + /** + * Asserts that the conversation with the specified subject is displayed. + * + * @param subject The subject of the conversation. + */ fun assertConversationDisplayed(subject: String) { val matcher = withText(subject) waitForView(matcher).scrollTo().assertDisplayed() } + /** + * Asserts that the conversation with the specified subject is not displayed. + * + * @param subject The subject of the conversation. + */ fun assertConversationNotDisplayed(subject: String) { val matcher = withText(subject) onView(matcher).check(ViewAssertions.doesNotExist()) } + /** + * Asserts the visibility of the unread marker for the conversation with the specified subject. + * + * @param subject The subject of the conversation. + * @param visibility The expected visibility of the unread marker. + */ fun assertUnreadMarkerVisibility(subject: String, visibility: ViewMatchers.Visibility) { val matcher = Matchers.allOf( withId(R.id.unreadMark), @@ -163,44 +250,80 @@ class InboxPage: BasePage() { } } + /** + * Selects the conversation with the specified subject. + * + * @param conversationSubject The subject of the conversation to select. + */ fun selectConversation(conversationSubject: String) { waitForView(withId(R.id.inboxRecyclerView)) val matcher = withText(conversationSubject) onView(matcher).scrollTo().longClick() } + /** + * Selects the conversation with the specified subject. + * + * @param conversation The conversation to select. + */ fun selectConversation(conversation: Conversation) { selectConversation(conversation.subject!!) } + /** + * Selects the conversation with the specified subject. + * + * @param conversation The conversation to select. + */ fun selectConversation(conversation: ConversationApiModel) { selectConversation(conversation.subject!!) } + /** + * Clicks the archive option in the action mode. + */ fun clickArchive() { waitForViewWithId(R.id.inboxArchiveSelected).click() } + /** + * Clicks the unarchive option in the action mode. + */ fun clickUnArchive() { waitForViewWithId(R.id.inboxUnarchiveSelected).click() } + /** + * Clicks the star option in the action mode. + */ fun clickStar() { waitForViewWithId(R.id.inboxStarSelected).click() } + /** + * Clicks the unstar option in the action mode. + */ fun clickUnstar() { waitForViewWithId(R.id.inboxUnstarSelected).click() } + /** + * Clicks the mark as read option in the action mode. + */ fun clickMarkAsRead() { waitForViewWithId(R.id.inboxMarkAsReadSelected).click() } + /** + * Clicks the mark as unread option in the action mode. + */ fun clickMarkAsUnread() { waitForViewWithId(R.id.inboxMarkAsUnreadSelected).click() } + /** + * Clicks the delete option in the action mode. + */ fun clickDelete() { Espresso.openActionBarOverflowOrOptionsMenu( InstrumentationRegistry.getInstrumentation().getTargetContext() @@ -209,24 +332,47 @@ class InboxPage: BasePage() { .perform(ViewActions.click()); } + /** + * Confirms the delete action. + */ fun confirmDelete() { waitForView(withText("DELETE") + withAncestor(R.id.buttonPanel)).click() } + /** + * Swipes the conversation with the specified subject to the right. + * + * @param conversationSubject The subject of the conversation to swipe. + */ fun swipeConversationRight(conversationSubject: String) { waitForView(withId(R.id.inboxRecyclerView)) val matcher = withText(conversationSubject) onView(matcher).scrollTo().swipeRight() } + /** + * Swipes the conversation to the right. + * + * @param conversation The conversation to swipe. + */ fun swipeConversationRight(conversation: ConversationApiModel) { swipeConversationRight(conversation.subject!!) } + /** + * Swipes the conversation to the right. + * + * @param conversation The conversation to swipe. + */ fun swipeConversationRight(conversation: Conversation) { swipeConversationRight(conversation.subject!!) } + /** + * Swipes the conversation with the specified subject to the left. + * + * @param conversationSubject The subject of the conversation to swipe. + */ fun swipeConversationLeft(conversationSubject: String) { waitForView(withId(R.id.inboxRecyclerView)) val matcher = withText(conversationSubject) @@ -234,32 +380,63 @@ class InboxPage: BasePage() { onView(matcher).swipeLeft() } + /** + * Swipes the conversation to the left. + * + * @param conversation The conversation to swipe. + */ fun swipeConversationLeft(conversation: Conversation) { swipeConversationLeft(conversation.subject!!) } + /** + * Swipes the conversation to the left. + * + * @param conversation The conversation to swipe. + */ fun swipeConversationLeft(conversation: ConversationApiModel) { swipeConversationLeft(conversation.subject!!) } + /** + * Selects multiple conversations. + * + * @param conversations The list of conversation subjects to select. + */ fun selectConversations(conversations: List) { for(conversation in conversations) { selectConversation(conversation) } } + /** + * Asserts the selected conversation number in the edit toolbar. + * + * @param selectedConversationNumber The expected selected conversation number. + */ fun assertSelectedConversationNumber(selectedConversationNumber: String) { onView(withText(selectedConversationNumber) + withAncestor(R.id.editToolbar)) } + /** + * Asserts the visibility of the edit toolbar. + * + * @param visibility The expected visibility of the edit toolbar. + */ fun assertEditToolbarIs(visibility: ViewMatchers.Visibility) { editToolbar.assertVisibility(visibility) } + /** + * Asserts that the star icon is displayed. + */ fun assertStarDisplayed() { waitForViewWithId(R.id.inboxStarSelected).assertDisplayed() } + /** + * Asserts that the unstar icon is displayed. + */ fun assertUnStarDisplayed() { waitForViewWithId(R.id.inboxUnstarSelected).assertDisplayed() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LeftSideNavigationDrawerPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LeftSideNavigationDrawerPage.kt index fdb44c0cd2..2b64f29b24 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LeftSideNavigationDrawerPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LeftSideNavigationDrawerPage.kt @@ -22,6 +22,13 @@ import com.instructure.teacher.R import org.hamcrest.CoreMatchers import org.hamcrest.Matcher +/** + * Represents the Left Side Navigation Drawer Page. + * + * This page extends the BasePage class and provides functionality for interacting with the left side + * navigation drawer. It contains various view elements such as user name, user email, logout button, + * version, hamburger button, and hamburger button matcher. + */ class LeftSideNavigationDrawerPage: BasePage() { private val userName by OnViewWithId(R.id.navigationDrawerUserName) @@ -37,11 +44,20 @@ class LeftSideNavigationDrawerPage: BasePage() { ViewMatchers.isDisplayed() ) + /** + * Clicks the menu item with the specified ID in the navigation drawer. + * + * @param menuId The ID of the menu item to click. + */ private fun clickMenu(menuId: Int) { onView(hamburgerButtonMatcher).click() onViewWithId(menuId).scrollTo().click() } + /** + * Performs the logout action from the navigation drawer. + * It waits for the sign-out to take effect before returning. + */ fun logout() { onView(hamburgerButtonMatcher).click() logoutButton.scrollTo().click() @@ -53,28 +69,50 @@ class LeftSideNavigationDrawerPage: BasePage() { )) } + /** + * Clicks the "Change User" menu item in the navigation drawer. + */ fun clickChangeUserMenu() { clickMenu(R.id.navigationDrawerItem_changeUser) } + /** + * Clicks the "Help" menu item in the navigation drawer. + */ fun clickHelpMenu() { clickMenu(R.id.navigationDrawerItem_help) } + /** + * Clicks the "Files" menu item in the navigation drawer. + */ fun clickFilesMenu() { clickMenu(R.id.navigationDrawerItem_files) } + /** + * Clicks the "Settings" menu item in the navigation drawer. + */ fun clickSettingsMenu() { clickMenu(R.id.navigationDrawerSettings) } + /** + * Sets the color overlay in the navigation drawer. + * + * @param colorOverlay Flag indicating whether to enable color overlay. + */ fun setColorOverlay(colorOverlay: Boolean) { hamburgerButton.click() onViewWithId(R.id.navigationDrawerColorOverlaySwitch).perform(SetSwitchCompat(colorOverlay)) Espresso.pressBack() } + /** + * Asserts that the specified user is logged in. + * + * @param user The user to assert being logged in. + */ fun assertUserLoggedIn(user: CanvasUserApiModel) { onView(hamburgerButtonMatcher).click() onViewWithText(user.shortName).assertDisplayed() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LegalPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LegalPage.kt index 900f207bc8..af9a2c6509 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LegalPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LegalPage.kt @@ -16,32 +16,42 @@ */ package com.instructure.teacher.ui.pages -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withParent -import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.espresso.OnViewWithId -import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.teacher.R -import org.hamcrest.Matchers.allOf +/** + * Represents the Legal Page. + * + * This page extends the BasePage class and provides functionality for interacting with the legal + * page. It contains various view elements such as privacy policy label, terms of use label, + * and open source label. + */ class LegalPage : BasePage(R.id.legalPage) { + private val privacyPolicyLabel by OnViewWithId(R.id.privacyPolicyLabel) private val termsOfUseLabel by OnViewWithId(R.id.termsOfUseLabel) private val openSourceLabel by OnViewWithId(R.id.openSourceLabel) + /** + * Opens the privacy policy. + */ fun openPrivacyPolicy() { privacyPolicyLabel.click() } + /** + * Opens the terms of use. + */ fun openTermsOfUse() { termsOfUseLabel.click() } + /** + * Opens the Canvas on GitHub. + */ fun openCanvasOnGithub() { openSourceLabel.click() } - -} \ No newline at end of file +} diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginFindSchoolPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginFindSchoolPage.kt index d6335cd936..f8f1d185e3 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginFindSchoolPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginFindSchoolPage.kt @@ -6,6 +6,13 @@ import com.instructure.espresso.page.BasePage import com.instructure.espresso.replaceText import com.instructure.teacher.R +/** + * Represents the Login Find School Page. + * + * This page extends the BasePage class and provides functionality for interacting with the login + * find school page. It contains various view elements such as toolbar, "What's your school name" + * text view, dividers, domain input edit text, find school recycler view, and toolbar next menu button. + */ @Suppress("unused") class LoginFindSchoolPage: BasePage() { @@ -17,11 +24,20 @@ class LoginFindSchoolPage: BasePage() { private val findSchoolRecyclerView by OnViewWithId(R.id.findSchoolRecyclerView) private val toolbarNextMenuButton by OnViewWithId(R.id.next) + /** + * Clicks the toolbar next menu item. + */ fun clickToolbarNextMenuItem() { toolbarNextMenuButton.click() } + /** + * Enters the domain into the domain input edit text. + * + * @param domain The domain to enter. + */ fun enterDomain(domain: String) { domainInputEditText.replaceText(domain) } } + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginLandingPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginLandingPage.kt index a991a3a6f1..db7cb74fad 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginLandingPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginLandingPage.kt @@ -9,6 +9,15 @@ import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onViewWithText import com.instructure.teacher.R +/** + * Represents the Login Landing Page. + * + * This page extends the BasePage class and provides functionality for interacting with the login + * landing page. It contains various view elements such as canvas logo image view, find my school button, + * find another school button, last saved school button, canvas network text view, previous login wrapper, + * previous login title text, previous login divider, previous login recycler view, canvas wordmark view, + * and app description type text view. + */ @Suppress("unused") class LoginLandingPage : BasePage() { @@ -24,39 +33,71 @@ class LoginLandingPage : BasePage() { private val canvasWordmarkView by OnViewWithId(R.id.canvasWordmark, autoAssert = false) private val appDescriptionTypeTextView by OnViewWithId(R.id.appDescriptionType, autoAssert = false) + /** + * Clicks the "Find My School" button. + */ fun clickFindMySchoolButton() { findMySchoolButton.click() } + /** + * Clicks the "Find Another School" button. + */ fun clickFindAnotherSchoolButton() { findAnotherSchoolButton.click() } + /** + * Clicks on the "Last Saved School" button. + */ fun clickOnLastSavedSchoolButton() { lastSavedSchoolButton.click() } + /** + * Clicks on the "Canvas Network" button. + */ fun clickCanvasNetworkButton() { canvasNetworkTextView.click() } + /** + * Asserts that the canvas wordmark view is displayed. + */ fun assertDisplaysCanvasWorkmark() { canvasWordmarkView.assertDisplayed() } + /** + * Asserts that the app description type text view is displayed. + */ fun assertDisplaysAppDescriptionType() { appDescriptionTypeTextView.assertDisplayed() } + /** + * Asserts that the previous logins section is displayed. + */ fun assertDisplaysPreviousLogins() { previousLoginTitleText.assertDisplayed() } + /** + * Logs in with the previous user specified. + * + * @param previousUser The previous user to log in with. + */ fun loginWithPreviousUser(previousUser: CanvasUserApiModel) { onViewWithText(previousUser.name).click() } + /** + * Logs in with the previous user specified. + * + * @param previousUser The previous user to log in with. + */ fun loginWithPreviousUser(previousUser: User) { onViewWithText(previousUser.name).click() } } + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginSignInPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginSignInPage.kt index 8f307bf5a1..1324380c18 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginSignInPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/LoginSignInPage.kt @@ -16,6 +16,13 @@ import com.instructure.espresso.page.BasePage import com.instructure.teacher.R import org.hamcrest.CoreMatchers +/** + * Represents the Login Sign-In Page. + * + * This page extends the BasePage class and provides functionality for interacting with the login + * sign-in page. It contains various view elements and helper methods for locating and interacting + * with UI elements on the page. + */ @Suppress("unused") class LoginSignInPage : BasePage() { @@ -59,6 +66,11 @@ class LoginSignInPage : BasePage() { //region Assertion Helpers + /** + * Asserts the presence of page objects on the Sign-In page. + * + * @param duration The duration to wait for the assertion. + */ override fun assertPageObjects(duration: Long) { signInRoot.assertDisplayed() toolbar.assertDisplayed() @@ -73,24 +85,45 @@ class LoginSignInPage : BasePage() { //region UI Action Helpers + /** + * Enters the email into the email field. + * + * @param email The email to enter. + */ fun enterEmail(email: String) { emailField().perform(clearElement()) emailField().perform(webKeys(email)) } + /** + * Enters the password into the password field. + * + * @param password The password to enter. + */ fun enterPassword(password: String) { passwordField().perform(clearElement()) passwordField().perform(webKeys(password)) } + /** + * Clicks the login button. + */ fun clickLoginButton() { loginButton().perform(webClick()) } + /** + * Clicks the forgot password button. + */ fun clickForgotPasswordButton() { forgotPasswordButton().perform(webClick()) } + /** + * Asserts the login error message. + * + * @param errorMessage The expected error message. + */ fun assertLoginErrorMessage(errorMessage: String) { loginErrorMessageHolder().check( WebViewAssertions.webMatches( @@ -100,10 +133,21 @@ class LoginSignInPage : BasePage() { ) } + /** + * Logs in as the specified teacher. + * + * @param teacher The teacher to log in as. + */ fun loginAs(teacher: CanvasUserApiModel) { loginAs(teacher.loginId, teacher.password) } + /** + * Logs in with the specified login ID and password. + * + * @param loginId The login ID to enter. + * @param password The password to enter. + */ fun loginAs(loginId: String, password: String) { enterEmail(loginId) enterPassword(password) @@ -118,3 +162,4 @@ class LoginSignInPage : BasePage() { //endregion } + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt index 7b8a26d012..f0f8b3d0f5 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt @@ -18,50 +18,99 @@ import com.instructure.espresso.waitForCheck import com.instructure.teacher.R import org.hamcrest.Matchers.allOf +/** + * Represents the Modules Page. + * + * This page extends the BasePage class and provides functionality for interacting with the modules + * page. It contains various assertion methods to verify the presence and visibility of module items + * and icons. It also includes methods for refreshing the page and clicking on the collapse/expand icon. + */ class ModulesPage : BasePage() { + /** + * Asserts that the empty view is displayed on the modules page. + */ fun assertEmptyView() { onView(allOf(withId(R.id.moduleListEmptyView), withAncestor(R.id.moduleList))).assertDisplayed() } + /** + * Asserts that the module is not published. + */ fun assertModuleNotPublished() { onView(withId(R.id.unpublishedIcon)).assertDisplayed() onView(withId(R.id.publishedIcon)).assertNotDisplayed() } + /** + * Asserts that the module is published. + */ fun assertModuleIsPublished() { onView(withId(R.id.unpublishedIcon)).assertNotDisplayed() onView(withId(R.id.publishedIcon)).assertDisplayed() } + /** + * Asserts that the module with the specified title is displayed. + * + * @param moduleTitle The title of the module. + */ fun assertModuleIsDisplayed(moduleTitle: String) { onView(allOf(withId(R.id.moduleName), withText(moduleTitle))).assertDisplayed() } + /** + * Refreshes the modules page. + */ fun refresh() { onView(allOf(withId(R.id.swipeRefreshLayout), withAncestor(R.id.moduleList))).swipeDown() } + /** + * Asserts that the module item with the specified title is displayed. + * + * @param itemTitle The title of the module item. + */ fun assertModuleItemIsDisplayed(itemTitle: String) { onView(allOf(withId(R.id.moduleItemTitle), withText(itemTitle))).assertDisplayed() } + /** + * Asserts that the module item with the specified name is published. + * + * @param moduleItemName The name of the module item. + */ fun assertModuleItemIsPublished(moduleItemName: String) { val siblingChildMatcher = withChild(withId(R.id.moduleItemTitle) + withText(moduleItemName)) onView(withId(R.id.moduleItemPublishedIcon) + hasSibling(siblingChildMatcher)).assertDisplayed() onView(withId(R.id.moduleItemUnpublishedIcon) + hasSibling(siblingChildMatcher)).assertNotDisplayed() } + /** + * Asserts that the module item with the specified title is not published. + * + * @param moduleTitle The title of the module. + * @param moduleItemName The name of the module item. + */ fun assertModuleItemNotPublished(moduleTitle: String, moduleItemName: String) { val siblingChildMatcher = withChild(withId(R.id.moduleItemTitle) + withText(moduleItemName)) onView(withId(R.id.moduleItemUnpublishedIcon) + hasSibling(siblingChildMatcher)).assertDisplayed() onView(withId(R.id.moduleItemPublishedIcon) + hasSibling(siblingChildMatcher)).assertNotDisplayed() } + /** + * Clicks on the collapse/expand icon. + */ fun clickOnCollapseExpandIcon() { onView(withId(R.id.collapseIcon)).click() } + /** + * Asserts the item count in the module with the specified title. + * + * @param moduleTitle The title of the module. + * @param expectedCount The expected item count in the module. + */ fun assertItemCountInModule(moduleTitle: String, expectedCount: Int) { onView(withId(R.id.recyclerView) + withDescendant(withId(R.id.moduleName) + withText(moduleTitle))).waitForCheck(RecyclerViewItemCountAssertion(expectedCount + 1)) // Have to increase by one because of the module title element itself. diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/NavDrawerPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/NavDrawerPage.kt index 4ef2c69834..f4159fe6c8 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/NavDrawerPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/NavDrawerPage.kt @@ -7,6 +7,12 @@ import com.instructure.espresso.OnViewWithId import com.instructure.espresso.page.BasePage import com.instructure.teacher.R +/** + * Represents the Navigation Drawer Page. + * + * This page extends the BasePage class and provides functionality for interacting with the navigation drawer. + * It contains assertion methods to verify the profile details of the user displayed in the navigation drawer. + */ class NavDrawerPage: BasePage() { private val settings by OnViewWithId(R.id.navigationDrawerSettings) @@ -16,7 +22,13 @@ class NavDrawerPage: BasePage() { private val logout by OnViewWithId(R.id.navigationDrawerItem_logout) private val version by OnViewWithId(R.id.navigationDrawerVersion) + /** + * Asserts the profile details of the user displayed in the navigation drawer. + * + * @param teacher The user whose profile details are expected to be displayed. + */ fun assertProfileDetails(teacher: User) { userName.check(matches(withText(teacher.shortName))) } } + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/NotATeacherPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/NotATeacherPage.kt index 65bd64e4f2..d1ce0395c7 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/NotATeacherPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/NotATeacherPage.kt @@ -20,6 +20,12 @@ import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.teacher.R +/** + * Represents the Not a Teacher Page. + * + * This page extends the BasePage class and provides functionality for interacting with the elements on the "Not a Teacher" page. + * It contains a method to click on the login button. + */ class NotATeacherPage : BasePage() { private val notATeacherTitle by WaitForViewWithId(R.id.not_a_teacher_header, autoAssert = true) @@ -28,7 +34,9 @@ class NotATeacherPage : BasePage() { private val parentLink by WaitForViewWithId(R.id.parentLink) private val loginButton by WaitForViewWithId(R.id.login) - + /** + * Clicks on the login button. + */ fun clickOnLoginButton() { loginButton.click() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PageListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PageListPage.kt index b0f5afcb25..6704501117 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PageListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PageListPage.kt @@ -22,13 +22,28 @@ import androidx.test.espresso.matcher.ViewMatchers.hasSibling import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvasapi2.models.Page -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.waitForViewWithText +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText +import com.instructure.espresso.waitForCheck import com.instructure.teacher.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.containsString +/** + * Represents the Page List Page. + * + * This page extends the BasePage class and provides functionality for interacting with the elements on the "Page List" page. + * It contains methods for clicking on the create new page button, opening a page, performing a search, and asserting various page-related conditions. + */ class PageListPage : BasePage() { private val searchButton by OnViewWithId(R.id.search) @@ -39,77 +54,125 @@ class PageListPage : BasePage() { private val toolbar by OnViewWithId(R.id.pageListToolbar) + /** + * Clicks on the "Create New Page" button. + */ fun clickOnCreateNewPage() { onView(withId(R.id.createNewPage)).click() } + /** + * Asserts that the page list contains the specified page. + * + * @param page The page to be asserted. + */ fun assertHasPage(page: Page) { waitForViewWithText(page.title!!).assertDisplayed() } + /** + * Opens the specified page. + * + * @param pageTitle The title of the page to be opened. + */ fun openPage(pageTitle: String) { val matcher = getPageMatcherByTitle(pageTitle = pageTitle) scrollRecyclerView(R.id.pageRecyclerView, matcher) onView(matcher).click() } + /** + * Opens the search bar. + */ fun openSearch() { searchButton.click() } + /** + * Enters the search query in the search bar. + * + * @param query The search query to be entered. + */ fun enterSearchQuery(query: String) { searchInput.perform(ViewActions.replaceText(query)) } + /** + * Asserts the number of pages in the page list. + * + * @param count The expected page count. + */ fun assertPageCount(count: Int) { pageRecyclerView.waitForCheck(RecyclerViewItemCountAssertion(count)) } + /** + * Asserts that the front page with the specified title is displayed. + * + * @param pageTitle The title of the front page. + */ fun assertFrontPageDisplayed(pageTitle: String) { val matcher = getFrontPageMatcher(pageTitle = pageTitle) scrollRecyclerView(R.id.pageRecyclerView, matcher) Espresso.onView(matcher).assertDisplayed() } + /** + * Asserts that a page with the specified title is displayed. + * + * @param pageTitle The title of the page. + */ fun assertPageDisplayed(pageTitle: String) { assertCommonPageDisplayed(pageTitle) } + /** + * Asserts that a page with the specified title is unpublished. + * + * @param pageTitle The title of the page. + */ fun assertPageIsUnpublished(pageTitle: String) { checkPagePublishedStatus(pageTitle = pageTitle, published = false) } + /** + * Asserts that a page with the specified title is published. + * + * @param pageTitle The title of the page. + */ fun assertPageIsPublished(pageTitle: String) { checkPagePublishedStatus(pageTitle = pageTitle, published = true) } private fun checkPagePublishedStatus(pageTitle: String, published: Boolean) { onView(allOf( - withId(R.id.pageLayout), - withContentDescription(containsString(pageTitle)), - withContentDescription(containsString(if(published) "Published" else "Unpublished")))) - .assertDisplayed() + withId(R.id.pageLayout), + withContentDescription(containsString(pageTitle)), + withContentDescription(containsString(if (published) "Published" else "Unpublished")))) + .assertDisplayed() } + private fun assertCommonPageDisplayed(pageTitle: String) { val matcher = getPageMatcherByTitle(pageTitle) scrollRecyclerView(R.id.pageRecyclerView, matcher) onView(matcher).assertDisplayed() } - private fun getPageMatcherByTitle(pageTitle: String) : Matcher { + private fun getPageMatcherByTitle(pageTitle: String): Matcher { return allOf( - withId(R.id.pageTitle), - withText(pageTitle) + withId(R.id.pageTitle), + withText(pageTitle) ) } - private fun getFrontPageMatcher(pageTitle: String) : Matcher { + private fun getFrontPageMatcher(pageTitle: String): Matcher { return allOf( - withId(R.id.pageTitle), - withText(pageTitle), - hasSibling(allOf( - withId(R.id.statusIndicator), - withText(R.string.frontPage) - ))) + withId(R.id.pageTitle), + withText(pageTitle), + hasSibling(allOf( + withId(R.id.statusIndicator), + withText(R.string.frontPage) + ))) } } + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PeopleListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PeopleListPage.kt index aaeca1e760..009486633f 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PeopleListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PeopleListPage.kt @@ -38,19 +38,35 @@ import com.instructure.teacher.R import org.hamcrest.Matcher import org.hamcrest.Matchers +/** + * Represents the People List Page. + * + * This page extends the BasePage class and provides functionality for interacting with the elements on the "People List" page. + * It contains methods for clicking on a person, asserting the presence of a person in the list with optional role filtering, asserting the search result count, + * scrolling to a specific person, performing search actions, and asserting the visibility of the empty view and person role. + */ class PeopleListPage : BasePage(R.id.peopleListPage) { + /** + * Clicks on a person in the list. + * + * @param user The user representing the person to click on. + */ fun clickPerson(user: CanvasUserApiModel) { waitForViewWithText(user.name).click() } - fun assertPersonListed(person: CanvasUserApiModel, role: String? = null) - { - var matcher : Matcher? = null - if(role == null) { + /** + * Asserts that a person is listed in the people list. + * + * @param person The user representing the person to assert. + * @param role The role of the person (optional). + */ + fun assertPersonListed(person: CanvasUserApiModel, role: String? = null) { + var matcher: Matcher? = null + if (role == null) { matcher = Matchers.allOf(ViewMatchers.withText(person.name), withId(R.id.userName)) - } - else { + } else { matcher = Matchers.allOf( ViewMatchers.withText(person.name), withId(R.id.userName), @@ -59,7 +75,6 @@ class PeopleListPage : BasePage(R.id.peopleListPage) { withId(R.id.userRole), ViewMatchers.withText(role) ) - ) ) } @@ -67,11 +82,21 @@ class PeopleListPage : BasePage(R.id.peopleListPage) { Espresso.onView(matcher).assertDisplayed() } - + /** + * Asserts the expected search result count. + * + * @param expectedCount The expected count of search results. + */ fun assertSearchResultCount(expectedCount: Int) { - onView(withId(R.id.recyclerView) + withAncestor(R.id.swipeRefreshLayout)).check(matches(hasChildCount(expectedCount))) //because of the CircleImageView, it's always there + onView(withId(R.id.recyclerView) + withAncestor(R.id.swipeRefreshLayout)) + .check(matches(hasChildCount(expectedCount))) // because of the CircleImageView, it's always there } + /** + * Scrolls to the view that matches the given matcher. + * + * @param matcher The matcher representing the view to scroll to. + */ private fun scrollToMatch(matcher: Matcher) { Espresso.onView( Matchers.allOf( @@ -79,35 +104,58 @@ class PeopleListPage : BasePage(R.id.peopleListPage) { ViewMatchers.isDisplayed(), withAncestor(R.id.peopleListPage) ) + ).perform( + RecyclerViewActions.scrollTo( + ViewMatchers.hasDescendant( + matcher + ) + ) ) - .perform( - RecyclerViewActions.scrollTo( - ViewMatchers.hasDescendant( - matcher - ) - )) } + /** + * Clicks the search button. + */ fun clickSearchButton() { onView(withId(R.id.search)).click() } + /** + * Enters the search text. + * + * @param searchText The text to enter in the search input. + */ fun typeSearchInput(searchText: String) { onView(withId(R.id.search_src_text)).replaceText(searchText) } + /** + * Clicks the reset search text button. + */ fun clickResetSearchText() { waitForView(withId(R.id.search_close_btn)).click() } + /** + * Asserts that the empty view is displayed. + */ fun assertEmptyViewIsDisplayed() { waitForView(withText(R.string.no_items_to_display_short) + withId(R.id.title) + withAncestor(withId(R.id.emptyPandaView))).assertDisplayed() } + /** + * Asserts the role of a person in the list. + * + * @param personName The name of the person. + * @param role The user role to assert. + */ fun assertPersonRole(personName: String, role: UserRole) { onView(withId(R.id.userRole) + withText(role.roleName) + hasSibling(withId(R.id.userName) + withText(personName))).assertDisplayed() } + /** + * Enum class representing the user roles. + */ enum class UserRole(val roleName: String) { TEACHER("Teacher"), STUDENT("Student"), diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PersonContextPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PersonContextPage.kt index f1c7cbd30b..1a7718bdf0 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PersonContextPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PersonContextPage.kt @@ -29,6 +29,13 @@ import com.instructure.teacher.R import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.not +/** + * Represents the Person Context Page. + * + * This page extends the BasePage class and provides functionality for interacting with the elements on the "Person Context" page. + * It contains properties for accessing various views on the page such as the toolbar, student name, student email, course name, section name, and last activity. + * Additionally, it provides methods for asserting the display of course information, section name based on the user role, and the person name. + */ open class PersonContextPage : BasePage(R.id.studentContextPage) { val toolbar by WaitForViewWithId(R.id.toolbar) @@ -38,10 +45,20 @@ open class PersonContextPage : BasePage(R.id.studentContextPage) { val sectionName by WaitForViewWithId(R.id.sectionNameView) val lastActivity by WaitForViewWithId(R.id.lastActivityView, autoAssert = false) + /** + * Asserts the display of course information. + * + * @param course The course to assert. + */ fun assertDisplaysCourseInfo(course: CourseApiModel) { courseName.assertHasText(course.name) } + /** + * Asserts the section name view based on the user role. + * + * @param userRole The user role. + */ fun assertSectionNameView(userRole: UserRole) { when (userRole) { UserRole.TEACHER -> sectionName.check(matches(containsTextCaseInsensitive("Teacher"))) @@ -50,10 +67,18 @@ open class PersonContextPage : BasePage(R.id.studentContextPage) { } } + /** + * Asserts that the person name is displayed. + * + * @param personName The name of the person to assert. + */ fun assertPersonNameIsDisplayed(personName: String) { studentName.check(matches(withText(personName))).assertDisplayed() } + /** + * Enum class representing the user roles. + */ enum class UserRole { TEACHER, STUDENT, OBSERVER } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PostSettingsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PostSettingsPage.kt index f7e25cf4f3..57f4030342 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PostSettingsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PostSettingsPage.kt @@ -19,37 +19,74 @@ package com.instructure.teacher.ui.pages import com.instructure.espresso.WaitForViewWithId import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click -import com.instructure.espresso.page.* +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText import com.instructure.teacher.R +/** + * Represents the Post Settings Page. + * + * This page extends the BasePage class and provides functionality for interacting with the elements on the "Post Settings" page. + * It contains properties for accessing various views on the page such as the post policy tab layout and the empty view. + * Additionally, it provides methods for clicking on tabs, asserting post policy status count, clicking on post grades and hide grades buttons, and asserting the empty view. + */ class PostSettingsPage : BasePage() { private val postPolicyTabLayout by WaitForViewWithId(R.id.postPolicyTabLayout) private val emptyView by WaitForViewWithId(R.id.postEmptyLayout, autoAssert = false) + /** + * Clicks on the tab with the specified name. + * + * @param tabName The name of the tab to click. + */ fun clickOnTab(tabName: String) { onView(withText(tabName)).click() } + /** + * Clicks on the tab at the specified position. + * + * @param tab The position of the tab to click. + */ fun clickOnTab(tab: Int) { onView(withText(tab) + withAncestor(R.id.postPolicyTabLayout)).click() } + /** + * Asserts the post policy status count. + * + * @param expectedCount The expected count of the post policy status. + * @param hidden Indicates whether the post policy status is hidden or not. + */ fun assertPostPolicyStatusCount(expectedCount: Int, hidden: Boolean) { val statusMessageEnd = if(hidden) "hidden" else "posted" if (expectedCount == 1) onView(withId(R.id.postPolicyStatusCount) + withText("$expectedCount grade currently $statusMessageEnd")).assertDisplayed() else onView(withId(R.id.postPolicyStatusCount) + withText("$expectedCount grades currently $statusMessageEnd")).assertDisplayed() } + /** + * Clicks on the "Post Grades" button. + */ fun clickOnPostGradesButton() { onView(withId(R.id.postGradeButton) + withText(R.string.postGradesTab)).click() } + /** + * Clicks on the "Hide Grades" button. + */ fun clickOnHideGradesButton() { onView(withId(R.id.postGradeButton) + withText(R.string.hideGradesTab)).click() } + /** + * Asserts the empty view. + */ fun assertEmptyView() { onView(withId(R.id.postEmptyTitle) + withText(R.string.postPolicyAllPostedTitle)).assertDisplayed() onView(withId(R.id.postEmptyMessage) + withText(R.string.postPolicyAllPostedMessage)).assertDisplayed() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProfileSettingsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProfileSettingsPage.kt index a2f04f617b..7777898992 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProfileSettingsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProfileSettingsPage.kt @@ -25,6 +25,15 @@ import com.instructure.espresso.page.withId import com.instructure.espresso.page.withText import com.instructure.teacher.R +/** + * Represents the Profile Settings Page. + * + * This page extends the BasePage class and provides functionality for interacting with the elements on the "Profile Settings" page. + * It contains properties for accessing various views on the page such as the toolbar, profile banner, user's avatar, user's name, user's email, and user's bio. + * Additionally, it provides methods for clicking on the edit pencil icon and asserting the user's name. + * + * @param pageId The ID of the profile settings page. + */ open class ProfileSettingsPage : BasePage(R.id.profileSettingsPage) { private val toolbar by OnViewWithId(R.id.toolbar) @@ -34,10 +43,18 @@ open class ProfileSettingsPage : BasePage(R.id.profileSettingsPage) { private val usersEmail by OnViewWithId(R.id.usersEmail) private val usersBio by OnViewWithId(R.id.usersBio) + /** + * Clicks on the edit pencil icon. + */ fun clickEditPencilIcon() { onView(withId(R.id.menu_edit)).click() } + /** + * Asserts that the user's name is as expected. + * + * @param expectedName The expected name of the user. + */ fun assertUserNameIs(expectedName: String) { usersName.check(matches(withText(expectedName))) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizDetailsPage.kt index d2547c5bb8..19d6249ab0 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizDetailsPage.kt @@ -18,13 +18,34 @@ package com.instructure.teacher.ui.pages import androidx.test.InstrumentationRegistry import androidx.test.espresso.matcher.ViewMatchers.withId import com.instructure.canvasapi2.models.Quiz -import com.instructure.espresso.* +import com.instructure.espresso.OnViewWithContentDescription +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.OnViewWithText +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertContainsText +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertHasContentDescription +import com.instructure.espresso.assertHasText +import com.instructure.espresso.assertNotDisplayed +import com.instructure.espresso.assertNotHasText +import com.instructure.espresso.assertVisible +import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.scrollTo import com.instructure.espresso.page.waitForView +import com.instructure.espresso.swipeDown import com.instructure.teacher.R +/** + * Represents the Quiz Details page. + * + * This page extends the BasePage class and provides functionality for interacting with quiz details. + * It includes methods for asserting various aspects of the quiz details such as instructions, + * availability, submissions, quiz status, and quiz information. The page also includes view elements + * that can be accessed for performing assertions and interactions. The page has a specific resource ID + * associated with it, which is R.id.quizDetailsPage. + */ class QuizDetailsPage : BasePage(pageResId = R.id.quizDetailsPage) { private val backButton by OnViewWithContentDescription(R.string.abc_action_bar_up_description,false) @@ -49,28 +70,48 @@ class QuizDetailsPage : BasePage(pageResId = R.id.quizDetailsPage) { private val ungradedDonut by OnViewWithId(R.id.ungradedWrapper) private val notSubmittedDonut by OnViewWithId(R.id.notSubmittedWrapper) + /** + * Asserts that the instructions for the quiz are displayed. + */ fun assertDisplaysInstructions() { scrollTo(R.id.contentWebView) instructionsWebView.assertVisible() } + /** + * Asserts that the "No Instructions" view is displayed. + */ fun assertDisplaysNoInstructionsView() { noInstructionsTextView.assertVisible() } + /** + * Opens the All Dates page for the quiz. + */ fun openAllDatesPage() { dueDatesLayout.click() } + /** + * Opens the Edit page for the quiz. + */ fun openEditPage() { editButton.click() } + /** + * Opens the Submissions page for the quiz. + */ fun openSubmissionsPage() { scrollTo(R.id.viewAllSubmissions) viewAllSubmissions.click() } + /** + * Asserts the quiz details such as title and publish status. + * + * @param quiz The Quiz object representing the quiz details. + */ fun assertQuizDetails(quiz: Quiz) { quizTitleTextView.assertHasText(quiz.title!!) if (quiz.published) { @@ -80,6 +121,9 @@ class QuizDetailsPage : BasePage(pageResId = R.id.quizDetailsPage) { } } + /** + * Asserts that the quiz is closed and displays the "Closed" availability status. + */ fun assertQuizClosed() { availableFromTextView.assertNotDisplayed() availableToTextView.assertNotDisplayed() @@ -87,60 +131,106 @@ class QuizDetailsPage : BasePage(pageResId = R.id.quizDetailsPage) { availabilityTextView.assertHasText(com.instructure.teacher.R.string.closed) } + /** + * Asserts that the "From" date is filled and the "To" date is empty in the availability section. + */ fun assertToFilledAndFromEmpty() { availableFromTextView.assertDisplayed().assertHasText(R.string.no_date_filler) availableToTextView.assertDisplayed().assertNotHasText(R.string.no_date_filler) } + /** + * Asserts that the "To" date is filled and the "From" date is empty in the availability section. + */ fun assertFromFilledAndToEmpty() { availableToTextView.assertDisplayed().assertHasText(R.string.no_date_filler) availableFromTextView.assertDisplayed().assertNotHasText(R.string.no_date_filler) } + /** + * Asserts that the quiz name has changed to the specified new quiz name. + * + * @param newQuizName The new quiz name to assert. + */ fun assertQuizNameChanged(newQuizName: String) { quizTitleTextView.assertHasText(newQuizName) } + /** + * Asserts that the quiz points have changed to the specified new quiz points. + * + * @param newQuizPoints The new quiz points to assert. + */ fun assertQuizPointsChanged(newQuizPoints: String) { pointsTextView.assertContainsText(newQuizPoints) } + /** + * Asserts that at least one submission has been made for the quiz. + */ fun assertHasSubmitted() { val resources = InstrumentationRegistry.getTargetContext() gradedDonut.assertHasContentDescription(resources.getString(R.string.content_description_submission_donut_graded).format(1, 1)) } + /** + * Asserts that there are submissions that need grading for the quiz. + */ fun assertNeedsGrading() { val resources = InstrumentationRegistry.getTargetContext() ungradedDonut.assertHasContentDescription(resources.getString(R.string.content_description_submission_donut_needs_grading).format(1, 1)) } + /** + * Asserts that there are submissions that have not been submitted for the quiz. + */ fun assertNotSubmitted() { val resources = InstrumentationRegistry.getTargetContext() notSubmittedDonut.assertHasContentDescription(resources.getString(R.string.content_description_submission_donut_unsubmitted).format(1, 1)) } - + /** + * Asserts that the quiz title has changed to the specified new quiz title. + * + * @param newQuizTitle The new quiz title to assert. + */ fun assertQuizTitleChanged(newQuizTitle: String) { quizTitleTextView.assertHasText(newQuizTitle) } + /** + * Asserts that the access code has changed to the specified new code. + * + * @param newCode The new access code to assert. + */ fun assertAccessCodeChanged(newCode: String) { //TODO: accessCodeTextView.assertHasText(newCode) } + /** + * Waits for the page to finish rendering. + */ fun waitForRender() { waitForView(withId(R.id.quizDetailsPage)) } + /** + * Performs a refresh action on the page. + */ fun refresh() { waitForView(withId(R.id.swipeRefreshLayout)).swipeDown() } + /** + * Asserts that the quiz is unpublished. + */ fun assertQuizUnpublished() { onView(withId(R.id.publishStatusTextView)).assertHasText("Unpublished") } + /** + * Asserts that the quiz is published. + */ fun assertQuizPublished() { onView(withId(R.id.publishStatusTextView)).assertHasText("Published") } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizListPage.kt index 9499f5454e..1e32f11b1c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizListPage.kt @@ -18,54 +18,118 @@ package com.instructure.teacher.ui.pages import androidx.test.espresso.action.ViewActions import com.instructure.canvasapi2.models.Quiz -import com.instructure.espresso.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.waitForViewWithText import com.instructure.espresso.page.withId +import com.instructure.espresso.swipeDown +import com.instructure.espresso.waitForCheck import com.instructure.teacher.R +/** + * Represents the Quiz List Page. + * + * This page extends the BasePage class and provides functionality for interacting with the elements on the "Quiz List" page. + * It contains properties for accessing various views on the page such as the quiz list toolbar, quiz recycler view, search button, search input, and empty panda view. + * Additionally, it provides methods for asserting the display of the "No Quizzes" view, checking the presence of a quiz, clicking on a quiz, opening the search bar, entering a search query, + * asserting the quiz count, and refreshing the page. + */ class QuizListPage : BasePage() { + + /** + * The quiz list toolbar view on the page. + */ private val quizListToolbar by OnViewWithId(R.id.quizListToolbar) + /** + * The quiz recycler view on the page. + */ private val quizRecyclerView by OnViewWithId(R.id.quizRecyclerView) + /** + * The search button on the page. + */ private val searchButton by OnViewWithId(R.id.search) + /** + * The search input view on the page. + */ private val searchInput by WaitForViewWithId(androidx.appcompat.R.id.search_src_text) - //Only displayed when assignment list is empty + /** + * The empty panda view displayed when the quiz list is empty. + */ private val emptyPandaView by WaitForViewWithId(R.id.emptyPandaView) + /** + * Asserts the display of the "No Quizzes" view. + */ fun assertDisplaysNoQuizzesView() { emptyPandaView.assertDisplayed() } + /** + * Asserts the presence of a quiz on the page. + * + * @param quiz The quiz object representing the quiz to be checked. + */ fun assertHasQuiz(quiz: Quiz) { waitForViewWithText(quiz.title!!).assertDisplayed() } + /** + * Clicks on a quiz. + * + * @param quiz The quiz object representing the quiz to be clicked. + */ fun clickQuiz(quiz: Quiz) { clickQuiz(quiz.title!!) } + /** + * Clicks on a quiz based on its title. + * + * @param quizTitle The title of the quiz to be clicked. + */ fun clickQuiz(quizTitle: String) { waitForViewWithText(quizTitle).click() } + /** + * Opens the search bar. + */ fun openSearch() { searchButton.click() } + /** + * Enters a search query in the search input. + * + * @param query The search query to be entered. + */ fun enterSearchQuery(query: String) { searchInput.perform(ViewActions.replaceText(query)) } + /** + * Asserts the count of quizzes on the page. + * + * @param count The expected count of quizzes. + */ fun assertQuizCount(count: Int) { quizRecyclerView.waitForCheck(RecyclerViewItemCountAssertion(count)) } + /** + * Refreshes the page. + */ fun refresh() { onView(withId(R.id.swipeRefreshLayout)).swipeDown() } } + diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizSubmissionListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizSubmissionListPage.kt index 82f1824613..27363d9752 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizSubmissionListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizSubmissionListPage.kt @@ -21,101 +21,179 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.canvasapi2.models.User import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertGone +import com.instructure.espresso.assertHasText +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.onViewWithId +import com.instructure.espresso.page.onViewWithText +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForViewWithText +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withText import com.instructure.teacher.R +/** + * Represents the Quiz Submission List page. + * + * This page extends the BasePage class and provides functionality for interacting with quiz submissions. + * It includes methods for asserting various states of the submission list, filtering submissions, + * clicking on a submission, and adding a message. The page includes multiple view elements that can be accessed + * for performing assertions and interactions. The page does not have a specific resource ID associated with it. + */ class QuizSubmissionListPage : BasePage() { private val assignmentSubmissionListToolbar by OnViewWithId(R.id.assignmentSubmissionListToolbar) - private val assignmentSubmissionRecyclerView by OnViewWithId(R.id.submissionsRecyclerView) - private val assignmentSubmissionListFilterLabel by OnViewWithId(R.id.filterTitle) - private val assignmentSubmissionClearFilter by WaitForViewWithId(R.id.clearFilterTextView, false) - private val assignmentSubmissionFilterButton by OnViewWithId(R.id.submissionFilter) - private val assignmentSubmissionStatus by OnViewWithId(R.id.submissionStatus) - private val addMessageFAB by OnViewWithId(R.id.addMessage) - - //Only displayed when assignment list is empty private val emptyPandaView by WaitForViewWithId(R.id.emptyPandaView) + /** + * Asserts that the submission list view displays "No items" indicating no submissions. + */ fun assertDisplaysNoSubmissionsView() { onView(withText("No items") + withAncestor(R.id.emptyPandaView)).assertDisplayed() } + /** + * Asserts that the student submission is displayed in the submission list. + * + * @param canvasUser The student's CanvasUserApiModel. + */ fun assertHasStudentSubmission(canvasUser: CanvasUserApiModel) { waitForViewWithText(canvasUser.name).assertDisplayed() } + /** + * Asserts that the filter label displays "All Submissions". + */ fun assertFilterLabelAllSubmissions() { assignmentSubmissionListFilterLabel.assertHasText(R.string.all_submissions) } + /** + * Asserts that the clear filter button is displayed. + */ fun assertDisplaysClearFilter() { assignmentSubmissionClearFilter.assertDisplayed() } + /** + * Asserts that the clear filter button is not displayed. + */ fun assertClearFilterGone() { assignmentSubmissionClearFilter.assertGone() } + /** + * Clicks on the filter button. + */ fun clickFilterButton() { assignmentSubmissionFilterButton.click() } + /** + * Clicks on the "Filter submissions" option in the filter dialog. + */ fun clickFilterSubmissions() { onViewWithText("Filter submissions").click() } + /** + * Clicks the positive button in the dialog. + */ fun clickDialogPositive() { onViewWithId(android.R.id.button1).click() } + /** + * Clicks on a specific submission in the submission list. + * + * @param student The student's CanvasUserApiModel. + */ fun clickSubmission(student: CanvasUserApiModel) { waitForViewWithText(student.name).click() } + /** + * Clicks on a specific submission in the submission list. + * + * @param student The student. + */ fun clickSubmission(student: User) { waitForViewWithText(student.name).click() } + /** + * Filters the submissions by "Submitted Late". + */ fun filterSubmittedLate() { onView(withText(R.string.submitted_late)).perform(withCustomConstraints(click(), isDisplayingAtLeast(50))) } + /** + * Filters the submissions by "Pending Review". + */ fun filterPendingReview() { onView(withText(R.string.havent_been_graded)).perform(withCustomConstraints(click(), isDisplayingAtLeast(50))) } + /** + * Filters the submissions by "Not Graded". + */ fun filterNotGraded() { onView(withText(R.string.not_graded)).perform(withCustomConstraints(click(), isDisplayingAtLeast(50))) } + /** + * Asserts that the filter label text matches the specified resource string. + * + * @param text The resource string representing the filter label text. + */ fun assertFilterLabelText(text: Int) { assignmentSubmissionListFilterLabel.assertHasText(text) } + /** + * Asserts that the submission list has at least one submission. + */ fun assertHasSubmission() { assignmentSubmissionRecyclerView.check(RecyclerViewItemCountAssertion(1)) } + /** + * Asserts that the submission list does not have any submissions. + */ fun assertHasNoSubmission() { assignmentSubmissionRecyclerView.check(RecyclerViewItemCountAssertion(0)) } + /** + * Asserts that the submission status is displayed as "Missing". + */ fun assertSubmissionStatusMissing() { assignmentSubmissionStatus.assertHasText(R.string.submission_status_missing) } + /** + * Asserts that the submission status is displayed as "Submitted". + */ fun assertSubmissionStatusSubmitted() { assignmentSubmissionStatus.assertHasText(R.string.submission_status_submitted) } + /** + * Clicks on the "Add Message" floating action button. + */ fun clickAddMessage() { addMessageFAB.click() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/RemoteConfigSettingsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/RemoteConfigSettingsPage.kt index a9c5709b02..284274d18e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/RemoteConfigSettingsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/RemoteConfigSettingsPage.kt @@ -31,8 +31,21 @@ import com.instructure.pandautils.R import org.hamcrest.Matcher import org.hamcrest.Matchers +/** + * Represents the Remote Config Settings page. + * + * This page extends the BasePage class and provides functionality for interacting with remote configuration parameters. + * It includes methods for clicking on a remote config parameter value, clearing the focus of a remote config parameter value, + * and verifying the value of a remote config parameter. It also includes a private helper method to get the matcher for locating + * the remote config parameter value. The page is identified by the resource ID R.id.remoteConfigSettingsFragment. + */ class RemoteConfigSettingsPage : BasePage(R.id.remoteConfigSettingsFragment) { + /** + * Clicks on the value of the specified remote config parameter. + * + * @param param The remote config parameter to click on. + */ fun clickRemoteConfigParamValue(param: RemoteConfigParam) { val target = getParamValueMatcher(param) @@ -40,6 +53,11 @@ class RemoteConfigSettingsPage : BasePage(R.id.remoteConfigSettingsFragment) { onView(target).click() } + /** + * Clears the focus of the specified remote config parameter value. + * + * @param param The remote config parameter to clear the focus of. + */ fun clearRemoteConfigParamValueFocus(param: RemoteConfigParam) { val target = getParamValueMatcher(param) @@ -47,6 +65,12 @@ class RemoteConfigSettingsPage : BasePage(R.id.remoteConfigSettingsFragment) { onView(target).perform(clearFocus()) } + /** + * Verifies the value of the specified remote config parameter. + * + * @param param The remote config parameter to verify the value of. + * @param targetValue The expected value of the remote config parameter. + */ fun verifyRemoteConfigParamValue(param: RemoteConfigParam, targetValue: String) { val target = getParamValueMatcher(param) @@ -54,10 +78,16 @@ class RemoteConfigSettingsPage : BasePage(R.id.remoteConfigSettingsFragment) { onView(target).check(ViewAssertions.matches((ViewMatchers.withText(targetValue)))) } + /** + * Returns the matcher for locating the value of the specified remote config parameter. + * + * @param param The remote config parameter to get the matcher for. + * @return The matcher for locating the remote config parameter value. + */ private fun getParamValueMatcher(param: RemoteConfigParam): Matcher { val matcher = Matchers.allOf( - ViewMatchers.isAssignableFrom(EditText::class.java), - ViewMatchers.hasSibling(containsTextCaseInsensitive("${param.rc_name}:")) + ViewMatchers.isAssignableFrom(EditText::class.java), + ViewMatchers.hasSibling(containsTextCaseInsensitive("${param.rc_name}:")) ) return matcher diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SettingsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SettingsPage.kt index 025ac01352..fe53e7e9c8 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SettingsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SettingsPage.kt @@ -30,6 +30,15 @@ import com.instructure.espresso.page.withText import com.instructure.espresso.scrollTo import com.instructure.teacher.R +/** + * Represents the Settings page. + * + * This page provides functionality for interacting with the elements on the Settings page. It contains methods + * for opening various settings pages such as profile settings, push notifications, rate app dialog, legal page, about page, + * feature flags page, and remote config parameters page. It also includes methods for asserting the display of a + * five-star rating, opening the app theme settings, selecting an app theme, and asserting the text color of the app theme title + * and status. This page extends the BasePage class. + */ class SettingsPage : BasePage(R.id.settingsPage) { private val toolbar by OnViewWithId(R.id.toolbar) private val profileSettingLabel by OnViewWithId(R.id.profileButton) @@ -42,34 +51,58 @@ class SettingsPage : BasePage(R.id.settingsPage) { private val appThemeTitle by OnViewWithId(R.id.appThemeTitle) private val appThemeStatus by OnViewWithId(R.id.appThemeStatus) + /** + * Opens the profile settings page. + */ fun openProfileSettingsPage() { profileSettingLabel.scrollTo().click() } + /** + * Opens the push notifications page. + */ fun openPushNotificationsPage() { pushNotificationsLabel.scrollTo().click() } + /** + * Opens the rate app dialog. + */ fun openRateAppDialog() { rateAppLabel.scrollTo().click() } + /** + * Opens the legal page. + */ fun openLegalPage() { legalLabel.scrollTo().click() } + /** + * Opens the about page. + */ fun openAboutPage() { aboutLabel.scrollTo().click() } + /** + * Opens the feature flags page. + */ fun openFeatureFlagsPage() { featureFlagLabel.scrollTo().click() } + /** + * Opens the remote config parameters page. + */ fun openRemoteConfigParamsPage() { remoteConfigLabel.scrollTo().click() } + /** + * Asserts the display of a five-star rating. + */ fun assertFiveStarRatingDisplayed() { for (i in 1 until 6) { Espresso.onView(ViewMatchers.withId(R.id.star + i)) @@ -77,19 +110,36 @@ class SettingsPage : BasePage(R.id.settingsPage) { } } + /** + * Opens the app theme settings. + */ fun openAppThemeSettings() { appThemeTitle.scrollTo().click() } - fun selectAppTheme(appTheme: String) - { - onView(withText(appTheme) + withParent(R.id.select_dialog_listview)).click() + /** + * Selects the specified app theme. + * + * @param appTheme The app theme to select. + */ + fun selectAppTheme(appTheme: String) { + onView(withText(appTheme) + withParent(R.id.select_dialog_listview)).click() } + /** + * Asserts the text color of the app theme title. + * + * @param expectedTextColor The expected text color of the app theme title. + */ fun assertAppThemeTitleTextColor(expectedTextColor: String) { appThemeTitle.check(TextViewColorAssertion(expectedTextColor)) } + /** + * Asserts the text color of the app theme status. + * + * @param expectedTextColor The expected text color of the app theme status. + */ fun assertAppThemeStatusTextColor(expectedTextColor: String) { appThemeStatus.check(TextViewColorAssertion(expectedTextColor)) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderCommentsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderCommentsPage.kt index 89846c167c..865e2b82e5 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderCommentsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderCommentsPage.kt @@ -22,71 +22,151 @@ import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withParent import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvasapi2.models.Attachment -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertHasText +import com.instructure.espresso.assertVisible +import com.instructure.espresso.clearText +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.callOnClick +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.onViewWithId +import com.instructure.espresso.page.onViewWithText +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withParent +import com.instructure.espresso.page.withText +import com.instructure.espresso.scrollTo +import com.instructure.espresso.typeText import com.instructure.teacher.R import org.hamcrest.Matchers.allOf +/** + * Represents the SpeedGrader comments page. + * + * This page provides functionality for interacting with the elements on the SpeedGrader comments page. It contains methods + * for asserting the display of author name, comment text, comment attachment, submission, comment field text, and empty state. + * It also provides methods for typing a comment, clearing a comment, sending a comment, clicking on the comment field, + * adding a comment, sending a video comment, sending an audio comment, and asserting the display of video and audio comments. + * This page extends the BasePage class. + */ class SpeedGraderCommentsPage : BasePage() { private val commentEditText by OnViewWithId(R.id.commentEditText) private val sendCommentButton by WaitForViewWithId(R.id.sendCommentButton) private val addAttachmentButton by OnViewWithId(R.id.addAttachment) + /** + * Asserts the display of the author name. + * + * @param name The name of the author. + */ fun assertDisplaysAuthorName(name: String) { onView(withText(name)).assertVisible() } + /** + * Asserts the display of the comment text. + * + * @param comment The comment text. + */ fun assertDisplaysCommentText(comment: String) { waitForView(allOf(withId(R.id.commentTextView), withEffectiveVisibility(Visibility.VISIBLE))) - .assertHasText(comment) + .assertHasText(comment) } + /** + * Asserts the display of the comment attachment. + * + * @param attachment The attachment representing the comment attachment. + */ fun assertDisplaysCommentAttachment(attachment: Attachment) { onViewWithId(R.id.attachmentNameTextView).assertHasText(attachment.displayName!!) } + /** + * Asserts the display of the submission. + */ fun assertDisplaysSubmission() { onViewWithId(R.id.commentSubmissionAttachmentView).assertDisplayed() } + /** + * Asserts the display of the submission file. + * + * @param attachment The attachment representing the submission file. + */ fun assertDisplaysSubmissionFile(attachment: Attachment) { val parentMatcher = withParent(withId(R.id.commentSubmissionAttachmentView)) val match = onView(allOf(parentMatcher, withId(R.id.titleTextView))) match.assertHasText(attachment.displayName!!) } + /** + * Asserts that the comment field has the specified text. + * + * @param text The expected text in the comment field. + */ fun assertCommentFieldHasText(text: String) { commentEditText.assertHasText(text) } + /** + * Types the specified comment in the comment field. + * + * @param comment The comment to type. + */ fun typeComment(comment: String) { onView(withId(R.id.commentEditText) + withAncestor(R.id.commentInputContainer)).typeText(comment) } + /** + * Clears the comment field. + */ fun clearComment() { onView(withId(R.id.commentEditText) + withAncestor(R.id.commentInputContainer)).clearText() } + /** + * Sends the comment. + */ fun sendComment() { onView(withId(R.id.sendCommentButton) + withEffectiveVisibility(Visibility.VISIBLE)) .click() } + /** + * Clicks on the comment field. + */ fun clickCommentField() { commentEditText.click() } + /** + * Asserts the display of the empty state. + */ fun assertDisplaysEmptyState() { onViewWithText(R.string.no_submission_comments).assertDisplayed() } + /** + * Adds a comment with the specified text. + * + * @param comment The comment text. + */ fun addComment(comment: String) { commentEditText.typeText(comment) Espresso.closeSoftKeyboard() callOnClick(withId(R.id.sendCommentButton)) } + /** + * Sends a video comment. + */ fun sendVideoComment() { clickOnAttachmentButton() onView(allOf(withId(R.id.videoText), withText(R.string.addVideoComment))).click() @@ -96,6 +176,9 @@ class SpeedGraderCommentsPage : BasePage() { waitForView(withId(R.id.sendButton)).click() } + /** + * Sends an audio comment. + */ fun sendAudioComment() { clickOnAttachmentButton() onView(allOf(withId(R.id.audioText), withText(R.string.addAudioComment))).click() @@ -109,6 +192,9 @@ class SpeedGraderCommentsPage : BasePage() { addAttachmentButton.click() } + /** + * Asserts the display of a video comment. + */ fun assertVideoCommentDisplayed() { val videoCommentMatcher = allOf( withId(R.id.commentHolder), @@ -118,6 +204,9 @@ class SpeedGraderCommentsPage : BasePage() { waitForView(videoCommentMatcher).scrollTo().assertDisplayed() } + /** + * Asserts the display of an audio comment. + */ fun assertAudioCommentDisplayed() { val audioCommentMatcher = allOf( withId(R.id.commentHolder), @@ -127,14 +216,23 @@ class SpeedGraderCommentsPage : BasePage() { waitForView(audioCommentMatcher).scrollTo().assertDisplayed() } + /** + * Clicks on an audio comment. + */ fun clickOnAudioComment() { waitForView(allOf(withId(R.id.attachmentNameTextView), withText(R.string.mediaUploadAudio))).click() } + /** + * Clicks on a video comment. + */ fun clickOnVideoComment() { waitForView(allOf(withId(R.id.attachmentNameTextView), withText(R.string.mediaUploadVideo))).click() } + /** + * Asserts the display of the media comment preview. + */ fun assertMediaCommentPreviewDisplayed() { onView(allOf(withId(R.id.prepareMediaButton), withParent(R.id.mediaPreviewContainer))).assertDisplayed() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderFilesPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderFilesPage.kt index b6a5b618e1..328f1017c3 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderFilesPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderFilesPage.kt @@ -19,33 +19,61 @@ import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvasapi2.models.Attachment -import com.instructure.dataseeding.model.AttachmentApiModel -import com.instructure.espresso.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.click import com.instructure.espresso.matchers.RecyclerViewMatcher import com.instructure.espresso.page.BasePage import com.instructure.teacher.R import org.hamcrest.Matchers +/** + * Represents the SpeedGrader files page. + * + * This page provides functionality for interacting with the elements on the SpeedGrader files page. It contains methods + * for asserting the presence of an empty view, asserting the presence of files, asserting the selection of a file, + * and selecting a file. This page extends the BasePage class. + */ class SpeedGraderFilesPage : BasePage() { private val speedGraderFileRecyclerView by OnViewWithId(R.id.speedGraderFilesRecyclerView) private val emptySpeedGraderFileView by WaitForViewWithId(R.id.speedGraderFilesEmptyView) + /** + * Asserts the presence of an empty view. + */ fun assertDisplaysEmptyView() { emptySpeedGraderFileView.assertDisplayed() } + /** + * Asserts the presence of files with the given attachments. + * + * @param attachments The list of attachments representing the files. + */ fun assertHasFiles(attachments: MutableList) { speedGraderFileRecyclerView.check(RecyclerViewItemCountAssertion(attachments.size)) for (attachment in attachments) onView(withText(attachment.filename)) } + /** + * Asserts the selection of a file at the specified position. + * + * @param position The position of the file in the list. + */ fun assertFileSelected(position: Int) { onView(RecyclerViewMatcher(R.id.speedGraderFilesRecyclerView).atPosition(position)) - .check(ViewAssertions.matches(hasDescendant(Matchers.allOf(withId(R.id.isSelectedIcon), withEffectiveVisibility(Visibility.VISIBLE))))) + .check(ViewAssertions.matches(hasDescendant(Matchers.allOf(withId(R.id.isSelectedIcon), withEffectiveVisibility(Visibility.VISIBLE))))) } + /** + * Selects a file at the specified position. + * + * @param position The position of the file in the list. + */ fun selectFile(position: Int) { onView(RecyclerViewMatcher(R.id.speedGraderFilesRecyclerView).atPosition(position)).click() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderGradePage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderGradePage.kt index 9842d28355..fb024f7477 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderGradePage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderGradePage.kt @@ -18,14 +18,40 @@ package com.instructure.teacher.ui.pages import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isEnabled -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.OnViewWithText +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.WaitForViewWithStringText +import com.instructure.espresso.WaitForViewWithText +import com.instructure.espresso.assertContainsText +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertGone +import com.instructure.espresso.assertHasText +import com.instructure.espresso.assertVisible +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.getStringFromResource +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.onViewWithId +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.withId +import com.instructure.espresso.replaceText import com.instructure.teacher.R import org.hamcrest.Matchers import org.hamcrest.Matchers.not import java.text.DecimalFormat import java.util.* +/** + * Represents the SpeedGrader grade page. + * + * This page provides functionality for interacting with the elements on the SpeedGrader grade page. It contains methods + * for opening the grade dialog, entering a new grade, asserting the grade dialog, asserting the presence of a grade, + * asserting the visibility of the rubric, slider, and checkbox, asserting the values of the slider's max and min values, + * asserting the presence of an overgrade warning, clicking the excuse student button, asserting the student's excused + * status, and asserting the enabled or disabled state of the excuse and no grade buttons. This page extends the BasePage + * class. + */ class SpeedGraderGradePage : BasePage() { private val gradeContainer by OnViewWithId(R.id.gradeContainer) @@ -43,88 +69,161 @@ class SpeedGraderGradePage : BasePage() { private val confirmDialogButton by WaitForViewWithStringText(getStringFromResource(android.R.string.ok).uppercase(Locale.getDefault())) + /** + * Opens the grade dialog. + */ fun openGradeDialog() { onView(Matchers.allOf((withId(R.id.gradeTextContainer)), ViewMatchers.isDisplayed())).click() } + /** + * Enters a new grade into the grade dialog. + * + * @param grade The new grade to be entered. + */ fun enterNewGrade(grade: String) { gradeEditText.replaceText(grade) confirmDialogButton.click() } + /** + * Asserts the presence of the grade dialog. + */ fun assertGradeDialog() { customizeGradeTitle.assertDisplayed() } + /** + * Asserts the presence of a grade. + * + * @param grade The expected grade. + */ fun assertHasGrade(grade: String) { onView(Matchers.allOf((withId(R.id.gradeValueText)), ViewMatchers.isDisplayed())).assertContainsText(grade) } + /** + * Asserts that the rubric is hidden. + */ fun assertRubricHidden() { onViewWithId(R.id.rubricEditView).assertGone() } + /** + * Asserts that the rubric is visible. + */ fun assertRubricVisible() { onViewWithId(R.id.rubricEditView).assertVisible() } + /** + * Asserts that the slider is visible. + */ fun assertSliderVisible() { slider.assertVisible() } + /** + * Asserts that the slider is hidden. + */ fun assertSliderHidden() { slider.assertGone() } + /** + * Asserts that the checkbox is visible. + */ fun assertCheckboxVisible() { onViewWithId(R.id.excuseStudentCheckbox).assertVisible() } + /** + * Asserts that the checkbox is hidden. + */ fun assertCheckboxHidden() { onViewWithId(R.id.excuseStudentCheckbox).assertGone() } + /** + * Asserts the max value of the slider. + * + * @param value The expected max value. + */ fun assertSliderMaxValue(value: String) { onView(Matchers.allOf((withId(R.id.maxGrade)), ViewMatchers.isDisplayed())).assertContainsText(value) } + /** + * Asserts the min value of the slider. + * + * @param value The expected min value. + */ fun assertSliderMinValue(value: String) { onView(Matchers.allOf((withId(R.id.minGrade)), ViewMatchers.isDisplayed())).assertContainsText(value) } + /** + * Asserts the presence of an overgrade warning with the specified overgraded value. + * + * @param overgradedBy The overgraded value. + */ fun assertHasOvergradeWarning(overgradedBy: Double) { val numberFormatter = DecimalFormat("##.##") onView(Matchers.allOf((withId(R.id.gradeText)), ViewMatchers.isDisplayed())).assertHasText(getStringFromResource(R.string.speed_grader_overgraded_by, numberFormatter.format(overgradedBy))) } + /** + * Clicks the excuse student button. + */ fun clickExcuseStudentButton() { onViewWithId(R.id.excuseButton).click() } + /** + * Asserts that the student is excused. + */ fun assertStudentExcused() { waitForView(Matchers.allOf((withId(R.id.gradeValueText)), ViewMatchers.isDisplayed())).assertHasText(getStringFromResource(R.string.excused)) } + /** + * Asserts that the excuse button is enabled. + */ fun assertExcuseButtonEnabled() { onViewWithId(R.id.excuseButton).check(matches(isEnabled())) } + /** + * Asserts that the excuse button is disabled. + */ fun assertExcuseButtonDisabled() { onViewWithId(R.id.excuseButton).check(matches(not(isEnabled()))) } + /** + * Asserts that the no grade button is enabled. + */ fun assertNoGradeButtonEnabled() { onViewWithId(R.id.noGradeButton).check(matches(isEnabled())) } + /** + * Asserts that the no grade button is disabled. + */ fun assertNoGradeButtonDisabled() { onViewWithId(R.id.noGradeButton).check(matches(not(isEnabled()))) } + /** + * Clicks the no grade button. + */ fun clickNoGradeButton() { onViewWithId(R.id.noGradeButton).click() } + /** + * Asserts the absence of a grade. + */ fun assertHasNoGrade() { onViewWithId(R.id.gradeValueText).assertGone() onViewWithId(R.id.addGradeIcon).assertVisible() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderPage.kt index bfeb0a4c7b..f9f1edb891 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderPage.kt @@ -32,6 +32,15 @@ import org.hamcrest.Matchers import org.hamcrest.Matchers.allOf import java.util.* +/** + * Represents the SpeedGrader page. + * + * This page provides functionality for interacting with the elements on the SpeedGrader page. It contains methods for + * asserting various aspects of the page, such as the submission drop-down, tabs (grades and comments), file tab, grading + * student, page count, and different submission views. It also provides methods for selecting different tabs, swiping up + * the comments and grades tabs, navigating to the submission page, clicking the back button, and asserting the visibility + * of the comment library. This page extends the BasePage class. + */ @Suppress("unused") class SpeedGraderPage : BasePage() { @@ -46,44 +55,80 @@ class SpeedGraderPage : BasePage() { private val submissionVersionDialogTitle by WaitForViewWithText(R.string.submission_versions) private val commentLibraryContainer by OnViewWithId(R.id.commentLibraryFragmentContainer) + /** + * Asserts that the page has the submission drop-down. + */ fun assertHasSubmissionDropDown() { submissionDropDown.assertDisplayed() } + /** + * Selects the "Grades" tab. + */ fun selectGradesTab() { val gradesTabText = getStringFromResource(R.string.sg_tab_grade).uppercase(Locale.getDefault()) onView(allOf((withText(gradesTabText)), isDisplayed())).click() } + /** + * Selects the "Comments" tab. + */ fun selectCommentsTab() { commentsTab.click() } + /** + * Swipes up the "Comments" tab. + */ fun swipeUpCommentsTab() { commentsTab.swipeToTop() } + /** + * Swipes up the "Grades" tab. + */ fun swipeUpGradesTab() { gradeTab.swipeToTop() } + /** + * Selects the "Files" tab with the specified file count. + */ fun selectFilesTab(fileCount: Int) { val filesTab = waitForViewWithText(getStringFromResource(R.string.sg_tab_files_w_counter, fileCount).toUpperCase()) filesTab.click() } + /** + * Asserts that the student is being graded. + * + * @param student The student to be graded. + */ fun assertGradingStudent(student: CanvasUserApiModel) { onViewWithText(student.name).assertCompletelyDisplayed() } + /** + * Asserts that the student is being graded. + * + * @param student The student to be graded. + */ fun assertGradingStudent(student: User) { onViewWithText(student.name).assertCompletelyDisplayed() } + /** + * Navigates to the submission page at the specified index. + * + * @param index The index of the submission page to navigate to. + */ fun goToSubmissionPage(index: Int) { submissionPager.pageToItem(index) } + /** + * Clicks the back button. + */ fun clickBackButton() { try { Espresso.onView( @@ -96,35 +141,69 @@ class SpeedGraderPage : BasePage() { } catch (e: NoMatchingViewException) {} } + /** + * Asserts the page count of the submission pager. + * + * @param count The expected page count. + */ fun assertPageCount(count: Int) { submissionPager.check(ViewPagerItemCountAssertion(count)) } + /** + * Asserts that the text submission view is displayed. + */ fun assertDisplaysTextSubmissionView() { waitForViewWithId(R.id.contentWebView).assertVisible() } + /** + * Asserts that the text submission view with the student's name is displayed. + * + * @param studentName The name of the student. + */ fun assertDisplaysTextSubmissionViewWithStudentName(studentName: String) { onView(allOf(withText(studentName), isDisplayed())) } + /** + * Asserts that the empty state with the specified string resource is displayed. + * + * @param stringRes The string resource of the empty state. + */ fun assertDisplaysEmptyState(@StringRes stringRes: Int) { waitForViewWithText(stringRes).assertCompletelyDisplayed() } + /** + * Asserts that the URL submission link is displayed with the specified submission. + * + * @param submission The submission with the URL. + */ fun assertDisplaysUrlSubmissionLink(submission: SubmissionApiModel) { waitForViewWithId(R.id.urlTextView).assertCompletelyDisplayed().assertHasText(submission.url!!) } + /** + * Asserts that the URL submission link is displayed with the specified submission. + * + * @param submission The submission with the URL. + */ fun assertDisplaysUrlSubmissionLink(submission: Submission) { waitForViewWithId(R.id.urlTextView).assertCompletelyDisplayed().assertHasText(submission.url!!) } + /** + * Asserts that the URL web view is displayed. + */ fun assertDisplaysUrlWebView() { waitForViewWithId(R.id.urlTextView).click() waitForViewWithId(R.id.canvasWebView).assertCompletelyDisplayed() } + /** + * Asserts that the comment library is not visible. + */ fun assertCommentLibraryNotVisible() { commentLibraryContainer.check(ViewAssertions.matches(ViewMatchers.hasChildCount(0))) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderQuizSubmissionPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderQuizSubmissionPage.kt index 5ee101ce1c..640dad24e1 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderQuizSubmissionPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SpeedGraderQuizSubmissionPage.kt @@ -26,6 +26,14 @@ import com.instructure.espresso.page.scrollTo import com.instructure.espresso.page.waitForViewWithText import com.instructure.teacher.R +/** + * Represents the SpeedGrader Quiz Submission Page. + * + * This page provides functionality for interacting with the elements on the SpeedGrader Quiz Submission page. It contains + * methods for asserting the different states of the quiz submission, such as no submission state, view quiz state, and + * pending review state. It also provides methods for starting and finishing the review process. This page extends the + * BasePage class. + */ @Suppress("unused") class SpeedGraderQuizSubmissionPage : BasePage() { @@ -36,33 +44,48 @@ class SpeedGraderQuizSubmissionPage : BasePage() { private val gradeSubmissionButton by WaitForViewWithId(R.id.gradeQuizButton) private val viewQuizButton by WaitForViewWithId(R.id.viewQuizButton) + /** + * Asserts that the page shows the "No Submission" state. + */ fun assertShowsNoSubmissionState() { waitForViewWithText(R.string.noSubmissionTeacher) } + /** + * Asserts that the page shows the "View Quiz" state. + */ fun assertShowsViewQuizState() { viewQuizButton.assertVisible() } + /** + * Asserts that the page shows the "Pending Review" state. + */ fun assertShowsPendingReviewState() { pendingReviewLabel.assertVisible() gradeSubmissionButton.assertVisible() } + /** + * Starts the review process by clicking on the "Grade Submission" button. + */ fun startReview() { scrollTo(R.id.gradeQuizButton) gradeSubmissionButton.click() } + /** + * Finishes the review process by performing the necessary actions on the web view. + */ fun finishReview() { Web.onWebView() - .withElement( - DriverAtoms.findElement( - Locator.CSS_SELECTOR, - """button[class="btn btn-primary update-scores"]""" - ) + .withElement( + DriverAtoms.findElement( + Locator.CSS_SELECTOR, + """button[class="btn btn-primary update-scores"]""" ) - .perform(DriverAtoms.webClick()) + ) + .perform(DriverAtoms.webClick()) } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/StudentContextPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/StudentContextPage.kt index 2b77f6f493..6229683b7a 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/StudentContextPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/StudentContextPage.kt @@ -22,10 +22,23 @@ import com.instructure.espresso.* import com.instructure.espresso.page.* import com.instructure.teacher.R +/** + * Represents the Student Context Page. + * + * This page extends the PersonContextPage class and provides functionality for interacting with the elements on the + * Student Context page. It contains methods for asserting the display of student information, student grade, student + * submission, assignment listing, and assignment submission. It also provides a method for clicking on the new message + * button. + */ class StudentContextPage : PersonContextPage() { private val messageButton by WaitForViewWithId(R.id.messageButton) + /** + * Asserts the display of student information on the Student Context page. + * + * @param student The student for which the information should be displayed. + */ fun assertDisplaysStudentInfo(student: CanvasUserApiModel) { waitForView(withParent(R.id.toolbar) + withText(student.shortName)).assertDisplayed() studentName.assertHasText(student.shortName) @@ -33,22 +46,43 @@ class StudentContextPage : PersonContextPage() { onView(withId(R.id.gradeItems)).scrollTo().assertDisplayed() } + /** + * Asserts the student's grade on the Student Context page. + * + * @param grade The expected grade of the student. + */ fun assertStudentGrade(grade: String) { onView(withId(R.id.gradeBeforePosting)).assertHasText(grade) } + /** + * Asserts the student's submission count on the Student Context page. + * + * @param submittedCount The expected number of submitted items. + */ fun assertStudentSubmission(submittedCount: String) { onView(withId(R.id.submittedCount)).assertHasText(submittedCount) } + /** + * Asserts the listing of an assignment on the Student Context page. + * + * @param assignmentTitle The title of the assignment to be listed. + */ fun assertAssignmentListed(assignmentTitle: String) { onView(withId(R.id.assignmentTitle) + withText(assignmentTitle)).scrollTo().assertDisplayed() } + /** + * Asserts the submission status of an assignment on the Student Context page. + */ fun assertAssignmentSubmitted() { onView(withId(R.id.submissionStatus) + withText(R.string.submitted)).assertDisplayed() } + /** + * Clicks on the new message button on the Student Context page. + */ fun clickOnNewMessageButton() { messageButton.click() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SyllabusPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SyllabusPage.kt index 24048097d4..adda2fc21b 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SyllabusPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/SyllabusPage.kt @@ -31,39 +31,80 @@ import com.instructure.teacher.R import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.comparesEqualTo +/** + * Represents the Syllabus Page. + * + * This page extends the BasePage class and provides functionality for interacting with the elements on the Syllabus page. + * It contains methods for asserting the display of an item, the empty view, selecting tabs, selecting summary events, + * refreshing the page, opening the edit syllabus screen, asserting the display of the syllabus content, + * and asserting the successful save of the syllabus. + * + * @param pageId The ID of the Syllabus page. + */ open class SyllabusPage : BasePage(R.id.syllabusPage) { private val tabs by OnViewWithId(R.id.syllabusTabLayout) private val webView by WaitForViewWithId(R.id.contentWebView) + /** + * Asserts the display of an item on the Syllabus page. + * + * @param itemText The text of the item to be displayed. + */ fun assertItemDisplayed(itemText: String) { scrollRecyclerView(R.id.syllabusEventsRecyclerView, itemText) } + /** + * Asserts the presence of the empty view on the Syllabus page. + */ fun assertEmptyView() { onView(withId(R.id.syllabusEmptyView)).assertDisplayed() } + /** + * Selects the summary tab on the Syllabus page. + */ fun selectSummaryTab() { onView(containsTextCaseInsensitive("summary") + withAncestor(R.id.syllabusTabLayout)).click() } + /** + * Selects the syllabus tab on the Syllabus page. + */ fun selectSyllabusTab() { onView(containsTextCaseInsensitive("syllabus") + withAncestor(R.id.syllabusTabLayout)).click() } + /** + * Selects a summary event on the Syllabus page. + * + * @param name The name of the summary event to be selected. + */ fun selectSummaryEvent(name: String) { onView(containsTextCaseInsensitive(name)).click() } + /** + * Refreshes the Syllabus page by performing a swipe down action on the swipe refresh layout. + */ fun refresh() { onView(allOf(withId(R.id.swipeRefreshLayout), withAncestor(R.id.syllabusPage))).swipeDown() } + /** + * Opens the edit syllabus screen by clicking on the edit menu button. + */ fun openEditSyllabus() { onViewWithId(R.id.menu_edit).click() } + /** + * Asserts the display of the syllabus content on the Syllabus page. + * + * @param syllabusBody The expected body of the syllabus. + * @param shouldDisplayTabs Indicates whether the tabs should be displayed. Default is true. + */ fun assertDisplaysSyllabus(syllabusBody: String, shouldDisplayTabs: Boolean = true) { if (shouldDisplayTabs) tabs.assertDisplayed() else tabs.assertNotDisplayed() webView.assertDisplayed() @@ -72,6 +113,11 @@ open class SyllabusPage : BasePage(R.id.syllabusPage) { .check(WebViewAssertions.webMatches(getText(), comparesEqualTo(syllabusBody))) } + /** + * Asserts the successful save of the syllabus. + * + * @param activity The activity context for checking the toast message. + */ fun assertSuccessfulSave(activity: Activity) { checkToastText(R.string.syllabusSuccessfullyUpdated, activity) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/TodoPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/TodoPage.kt index 839d9bd51a..2dca2c710c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/TodoPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/TodoPage.kt @@ -28,31 +28,64 @@ import com.instructure.espresso.swipeDown import com.instructure.espresso.waitForCheck import com.instructure.teacher.R +/** + * Represents the To Do Page. + * + * This page extends the BasePage class and provides functionality for interacting with the elements on the To Do page. + * It contains methods for waiting for the page to render, asserting the display of To Do element details, + * refreshing the page, asserting the presence of an empty view, asserting the needs grading count of a To Do element, + * and asserting the count of To Do elements. + */ class TodoPage : BasePage() { + /** + * Waits for the To Do page to render by asserting the display of the To Do toolbar. + */ fun waitForRender() { onView(withId(R.id.toDoToolbar)).assertDisplayed() } + /** + * Asserts that the details of a To Do element are displayed. + * + * @param courseName The name of the course associated with the To Do element. + */ fun assertTodoElementDetailsDisplayed(courseName: String) { onView(withId(R.id.toDoCourse) + withText(courseName)).assertDisplayed() onView(withId(R.id.dueDate)).assertDisplayed() onView(withId(R.id.toDoTitle)).assertDisplayed() } + /** + * Refreshes the To Do page by performing a swipe down action on the swipe refresh layout. + */ fun refresh() { onView(withId(R.id.swipeRefreshLayout)).swipeDown() } + /** + * Asserts that an empty view is displayed on the To Do page. + */ fun assertEmptyView() { onView(withId(R.id.emptyPandaView)).assertDisplayed() } + /** + * Asserts the needs grading count of a To Do element. + * + * @param todoTitle The title of the To Do element. + * @param ungradedCount The number of ungraded items. + */ fun assertNeedsGradingCountOfTodoElement(todoTitle: String, ungradedCount: Int) { onView(withId(R.id.ungradedCount) + withText("$ungradedCount needs grading") + hasSibling(withId(R.id.toDoTitle) + withText(todoTitle))).assertDisplayed() } + /** + * Asserts the count of To Do elements on the To Do page. + * + * @param count The expected count of To Do elements. + */ fun assertTodoElementCount(count: Int) { onView(withId(R.id.toDoRecyclerView)).waitForCheck(RecyclerViewItemCountAssertion(count)) } -} \ No newline at end of file +} diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/WebViewLoginPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/WebViewLoginPage.kt index e79394a719..8559af1316 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/WebViewLoginPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/WebViewLoginPage.kt @@ -25,6 +25,14 @@ import androidx.test.espresso.web.webdriver.Locator import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.espresso.page.BasePage +/** + * Represents the WebView Login Page. + * + * This page extends the BasePage class and provides functionality for interacting with the elements on the WebView login page. + * It contains private constants for the CSS selectors of the email field, password field, and login button. + * Additionally, it provides methods for locating the email field, password field, and login button in the WebView, asserting the presence of these elements on the page, + * entering an email and password, clicking the login button, and logging in as a teacher. + */ @Suppress("unused") class WebViewLoginPage : BasePage() { @@ -32,51 +40,78 @@ class WebViewLoginPage : BasePage() { private val PASSWORD_FIELD_CSS = "input[name=\"pseudonym_session[password]\"]" private val LOGIN_BUTTON_CSS = "button[type=\"submit\"]" - //region UI Element Locator Methods - + /** + * Locates the email field in the WebView. + * + * @return The WebInteraction object representing the email field. + */ private fun emailField(): Web.WebInteraction<*> { return onWebView().withElement(findElement(Locator.CSS_SELECTOR, EMAIL_FIELD_CSS)) } + /** + * Locates the password field in the WebView. + * + * @return The WebInteraction object representing the password field. + */ private fun passwordField(): Web.WebInteraction<*> { return onWebView().withElement(findElement(Locator.CSS_SELECTOR, PASSWORD_FIELD_CSS)) } + /** + * Locates the login button in the WebView. + * + * @return The WebInteraction object representing the login button. + */ private fun loginButton(): Web.WebInteraction<*> { return onWebView().withElement(findElement(Locator.CSS_SELECTOR, LOGIN_BUTTON_CSS)) } - //endregion - - //region Assertion Helpers - + /** + * Asserts the presence of the email field, password field, and login button on the page. + * + * @param duration The duration to wait for the elements to be displayed. + */ override fun assertPageObjects(duration: Long) { emailField() passwordField() loginButton() } - //endregion - - //region UI Action Helpers - + /** + * Enters an email into the email field in the WebView. + * + * @param email The email to be entered. + */ fun enterEmail(email: String) { emailField().perform(webKeys(email)) } + /** + * Enters a password into the password field in the WebView. + * + * @param password The password to be entered. + */ fun enterPassword(password: String) { passwordField().perform(webKeys(password)) } + /** + * Clicks the login button in the WebView. + */ fun clickLoginButton() { loginButton().perform(webClick()) } + /** + * Logs in as a teacher by entering the teacher's email and password, and clicking the login button. + * + * @param teacher The teacher object representing the teacher's login credentials. + */ fun loginAs(teacher: CanvasUserApiModel) { enterEmail(teacher.loginId) enterPassword(teacher.password) clickLoginButton() } - - //endregion } + From 3a8b39aafb0e199bb74636382faab98ac0460eec Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Mon, 10 Jul 2023 13:15:00 +0200 Subject: [PATCH 03/61] Updated version. --- apps/student/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/student/build.gradle b/apps/student/build.gradle index d681217629..43afe3bf59 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -50,8 +50,8 @@ android { applicationId "com.instructure.candroid" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 252 - versionName = '6.25.0' + versionCode = 253 + versionName = '6.25.1' vectorDrawables.useSupportLibrary = true multiDexEnabled = true From 5c5e4c785a8a43462b26153b4e64dee99726f544 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Mon, 10 Jul 2023 13:58:15 +0200 Subject: [PATCH 04/61] [MBL-16891][Student] Crash in group files #2053 refs: MBL-16891 affects: Student release note: none --- .../com/instructure/student/fragment/FileListFragment.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt index de46927ecc..1a99197673 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt @@ -83,7 +83,10 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent if (canvasContext.type == CanvasContext.Type.USER) { url += "users_${canvasContext.id}/" } - url += folder?.fullName?.split(" ", limit = 2)?.get(1)?.replaceFirst("files/", "") ?: "" + val fullNameParts = folder?.fullName?.split("/", limit = 2) + if ((fullNameParts?.size ?: 0) > 1) { + url += fullNameParts?.get(1) ?: "" + } } return url From 435214a7ba7bdf3a71b15912f8ddb07f76578f2f Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Tue, 11 Jul 2023 16:28:17 +0200 Subject: [PATCH 05/61] Rollback lowres devices to API lvl 26 (because API level 29 low resolution device is too flaky). (#2056) --- apps/student/flank_e2e_lowres.yml | 2 +- apps/teacher/flank_e2e_lowres.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/student/flank_e2e_lowres.yml b/apps/student/flank_e2e_lowres.yml index a0595dafff..d7862027cb 100644 --- a/apps/student/flank_e2e_lowres.yml +++ b/apps/student/flank_e2e_lowres.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: NexusLowRes - version: 29 + version: 26 locale: en_US orientation: portrait diff --git a/apps/teacher/flank_e2e_lowres.yml b/apps/teacher/flank_e2e_lowres.yml index b6e24c1447..8e6947ac6d 100644 --- a/apps/teacher/flank_e2e_lowres.yml +++ b/apps/teacher/flank_e2e_lowres.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: NexusLowRes - version: 29 + version: 26 locale: en_US orientation: portrait From 273916a55e51d6369427e76a48f0a18fad1a4016 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Mon, 17 Jul 2023 12:35:07 +0200 Subject: [PATCH 06/61] [MBL-16904][Student][Teacher] Save environment feature flags (#2058) Test plan: Check if the feature flags are saved to the AppDatabase. refs: MBL-16904 affects: Student, Teacher release note: none --- .../student/activity/NavigationActivity.kt | 12 + .../CourseModuleProgressionFragment.kt | 11 +- .../teacher/activities/InitActivity.kt | 11 + .../canvasapi2/apis/FeaturesAPI.kt | 7 +- .../instructure/canvasapi2/di/ApiModule.kt | 6 + .../7.json | 504 +++++++++++++++++ .../8.json | 510 ++++++++++++++++++ .../appdatabase/daos/AttachmentDaoTest.kt | 19 +- .../daos/EnvironmentFeatureFlagsDaoTest.kt | 91 ++++ .../daos/SubmissionCommentDaoTest.kt | 22 + .../pandautils/di/DatabaseModule.kt | 6 + .../pandautils/di/FeatureFlagModule.kt | 6 +- .../room/appdatabase/AppDatabase.kt | 7 +- .../room/appdatabase/AppDatabaseMigrations.kt | 8 + .../daos/EnvironmentFeatureFlagsDao.kt | 35 ++ .../entities/EnvironmentFeatureFlags.kt | 29 + .../pandautils/room/common/Converters.kt | 12 + .../room/common/daos/AttachmentDao.kt | 7 +- .../room/common/daos/SubmissionCommentDao.kt | 8 +- .../room/common/entities/AttachmentEntity.kt | 63 ++- .../entities/SubmissionCommentEntity.kt | 21 +- .../pandautils/utils/FeatureFlagProvider.kt | 20 +- .../utils/FeatureFlagProviderTest.kt | 50 +- 23 files changed, 1400 insertions(+), 65 deletions(-) create mode 100644 libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/7.json create mode 100644 libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/8.json create mode 100644 libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDaoTest.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDao.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/EnvironmentFeatureFlags.kt diff --git a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt index e1d8e8cfa3..f665f6802c 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt @@ -45,6 +45,7 @@ import androidx.core.view.GravityCompat import androidx.core.view.MenuItemCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager +import androidx.lifecycle.lifecycleScope import com.airbnb.lottie.LottieAnimationView import com.google.android.material.bottomnavigation.BottomNavigationView import com.instructure.canvasapi2.CanvasRestAdapter @@ -130,6 +131,9 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. @Inject lateinit var updateManager: UpdateManager + @Inject + lateinit var featureFlagProvider: FeatureFlagProvider + private var routeJob: WeaveJob? = null private var debounceJob: Job? = null private var drawerItemSelectedJob: Job? = null @@ -266,6 +270,8 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. setupNavDrawerItems() + loadFeatureFlags() + checkAppUpdates() val savedBottomScreens = savedInstanceState?.getStringArrayList(BOTTOM_SCREENS_BUNDLE_KEY) @@ -280,6 +286,12 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. requestNotificationsPermission() } + private fun loadFeatureFlags() { + lifecycleScope.launch { + featureFlagProvider.fetchEnvironmentFeatureFlags() + } + } + private fun requestNotificationsPermission() { if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { notificationsPermissionContract.launch(Manifest.permission.POST_NOTIFICATIONS) diff --git a/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt index 98206784de..ef94c02f2d 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt @@ -58,23 +58,22 @@ import com.instructure.student.util.Const import com.instructure.student.util.CourseModulesStore import com.instructure.student.util.ModuleProgressionUtility import com.instructure.student.util.ModuleUtility +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import kotlinx.coroutines.launch import okhttp3.ResponseBody import retrofit2.Response +import javax.inject.Inject @PageView(url = "courses/{canvasContext}/modules") @ScreenView(SCREEN_VIEW_COURSE_MODULE_PROGRESSION) +@AndroidEntryPoint class CourseModuleProgressionFragment : ParentFragment(), Bookmarkable { private val binding by viewBinding(CourseModuleProgressionBinding::bind) - private val discussionRouteHelper = DiscussionRouteHelper( - FeaturesManager, - FeatureFlagProvider(UserManager, ApiPrefs), - DiscussionManager, - GroupManager - ) + @Inject + lateinit var discussionRouteHelper: DiscussionRouteHelper private var routeModuleProgressionJob: Job? = null private var moduleItemsJob: Job? = null diff --git a/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt b/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt index e00f79d35a..9bd92c4cbe 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt @@ -111,6 +111,9 @@ class InitActivity : BasePresenterActivity> @GET("features/environment") fun getEnvironmentFeatureFlags(): Call> + + @GET("features/environment") + suspend fun getEnvironmentFeatureFlags(@Tag restParams: RestParams): DataResult> } fun getEnabledFeaturesForCourse(adapter: RestBuilder, courseId: Long, callback: StatusCallback>, params: RestParams) { diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt index 8ac553e2fe..b3e7ddec68 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt @@ -1,6 +1,7 @@ package com.instructure.canvasapi2.di import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.FeaturesAPI import com.instructure.canvasapi2.apis.GroupAPI import com.instructure.canvasapi2.apis.HelpLinksAPI import com.instructure.canvasapi2.apis.InboxApi @@ -168,4 +169,9 @@ class ApiModule { fun provideProgressApi(): ProgressAPI.ProgressInterface { return RestBuilder().build(ProgressAPI.ProgressInterface::class.java, RestParams()) } + + @Provides + fun provideFeaturesApi(): FeaturesAPI.FeaturesInterface { + return RestBuilder().build(FeaturesAPI.FeaturesInterface::class.java, RestParams()) + } } \ No newline at end of file diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/7.json b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/7.json new file mode 100644 index 0000000000..de38825764 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/7.json @@ -0,0 +1,504 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "0d3dd1cb3dde3ba7d7a93ccddf7ccbf9", + "entities": [ + { + "tableName": "AttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contentType` TEXT, `filename` TEXT, `displayName` TEXT, `url` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `createdAt` INTEGER, `size` INTEGER NOT NULL, `workerId` TEXT, `submissionCommentId` INTEGER, `submissionId` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "submissionCommentId", + "columnName": "submissionCommentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AuthorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `avatarImageUrl` TEXT, `htmlUrl` TEXT, `pronouns` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarImageUrl", + "columnName": "avatarImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EnvironmentFeatureFlags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `featureFlags` TEXT NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "featureFlags", + "columnName": "featureFlags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FileUploadInputEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `courseId` INTEGER, `assignmentId` INTEGER, `quizId` INTEGER, `quizQuestionId` INTEGER, `position` INTEGER, `parentFolderId` INTEGER, `action` TEXT NOT NULL, `userId` INTEGER, `attachments` TEXT NOT NULL, `submissionId` INTEGER, `filePaths` TEXT NOT NULL, `attemptId` INTEGER, `notificationId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizId", + "columnName": "quizId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizQuestionId", + "columnName": "quizQuestionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filePaths", + "columnName": "filePaths", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MediaCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` TEXT NOT NULL, `displayName` TEXT, `url` TEXT, `mediaType` TEXT, `contentType` TEXT, PRIMARY KEY(`mediaId`))", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `authorId` INTEGER NOT NULL, `authorName` TEXT, `authorPronouns` TEXT, `comment` TEXT, `createdAt` INTEGER, `mediaCommentId` TEXT, `attemptId` INTEGER, `submissionId` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorPronouns", + "columnName": "authorPronouns", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mediaCommentId", + "columnName": "mediaCommentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PendingSubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pageId` TEXT NOT NULL, `comment` TEXT, `date` INTEGER NOT NULL, `status` TEXT NOT NULL, `workerId` TEXT, `filePath` TEXT, `attemptId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardFileUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT, `subtitle` TEXT, `courseId` INTEGER, `assignmentId` INTEGER, `attemptId` INTEGER, `folderId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0d3dd1cb3dde3ba7d7a93ccddf7ccbf9')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/8.json b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/8.json new file mode 100644 index 0000000000..315060d789 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/8.json @@ -0,0 +1,510 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "1e21f55714fd92fed820f2785cdb62d9", + "entities": [ + { + "tableName": "AttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contentType` TEXT, `filename` TEXT, `displayName` TEXT, `url` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `createdAt` INTEGER, `size` INTEGER NOT NULL, `workerId` TEXT, `submissionCommentId` INTEGER, `submissionId` INTEGER, `attempt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "submissionCommentId", + "columnName": "submissionCommentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attempt", + "columnName": "attempt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AuthorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `avatarImageUrl` TEXT, `htmlUrl` TEXT, `pronouns` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarImageUrl", + "columnName": "avatarImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EnvironmentFeatureFlags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `featureFlags` TEXT NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "featureFlags", + "columnName": "featureFlags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FileUploadInputEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `courseId` INTEGER, `assignmentId` INTEGER, `quizId` INTEGER, `quizQuestionId` INTEGER, `position` INTEGER, `parentFolderId` INTEGER, `action` TEXT NOT NULL, `userId` INTEGER, `attachments` TEXT NOT NULL, `submissionId` INTEGER, `filePaths` TEXT NOT NULL, `attemptId` INTEGER, `notificationId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizId", + "columnName": "quizId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizQuestionId", + "columnName": "quizQuestionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filePaths", + "columnName": "filePaths", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MediaCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` TEXT NOT NULL, `displayName` TEXT, `url` TEXT, `mediaType` TEXT, `contentType` TEXT, PRIMARY KEY(`mediaId`))", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `authorId` INTEGER NOT NULL, `authorName` TEXT, `authorPronouns` TEXT, `comment` TEXT, `createdAt` INTEGER, `mediaCommentId` TEXT, `attemptId` INTEGER, `submissionId` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorPronouns", + "columnName": "authorPronouns", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mediaCommentId", + "columnName": "mediaCommentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PendingSubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pageId` TEXT NOT NULL, `comment` TEXT, `date` INTEGER NOT NULL, `status` TEXT NOT NULL, `workerId` TEXT, `filePath` TEXT, `attemptId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardFileUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT, `subtitle` TEXT, `courseId` INTEGER, `assignmentId` INTEGER, `attemptId` INTEGER, `folderId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1e21f55714fd92fed820f2785cdb62d9')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/AttachmentDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/AttachmentDaoTest.kt index 3f91092fce..652c98bb0c 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/AttachmentDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/AttachmentDaoTest.kt @@ -53,7 +53,8 @@ class AttachmentDaoTest { @Test fun insertAndFindingByParentId() = runTest { - val attachmentEntity = AttachmentEntity(id = 1, contentType = "image/jpg", filename = "image.jpg", displayName = "File", + val attachmentEntity = AttachmentEntity( + id = 1, contentType = "image/jpg", filename = "image.jpg", displayName = "File", url = "file.com", createdAt = Date(), size = 10000, workerId = "123", submissionCommentId = 123 ) @@ -67,7 +68,8 @@ class AttachmentDaoTest { @Test fun dontReturnAnyItemIfEntitiesAreDeleted() = runTest { - val attachmentEntity = AttachmentEntity(id = 1, contentType = "image/jpg", filename = "image.jpg", displayName = "File", + val attachmentEntity = AttachmentEntity( + id = 1, contentType = "image/jpg", filename = "image.jpg", displayName = "File", url = "file.com", createdAt = Date(), size = 10000, workerId = "123", submissionCommentId = 123 ) @@ -80,4 +82,17 @@ class AttachmentDaoTest { Assert.assertEquals(0, result!!.size) } + @Test + fun testFindBySubmissionId() = runTest { + val attachmentEntity = AttachmentEntity( + id = 1, contentType = "image/jpg", filename = "image.jpg", displayName = "File", url = "file.com", + createdAt = Date(), size = 10000, workerId = "123", submissionCommentId = 123, submissionId = 1 + ) + val attachmentEntity2 = attachmentEntity.copy(id = 2, workerId = "124", filename = "image2.jpg", submissionId = 2) + attachmentDao.insertAll(listOf(attachmentEntity, attachmentEntity2)) + + val result = attachmentDao.findBySubmissionId(1) + + Assert.assertEquals(listOf(attachmentEntity), result) + } } \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDaoTest.kt new file mode 100644 index 0000000000..efe955856a --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDaoTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.room.appdatabase.daos + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.pandautils.room.appdatabase.AppDatabase +import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFlags +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class EnvironmentFeatureFlagsDaoTest { + + private lateinit var db: AppDatabase + private lateinit var environmentFeatureFlagsDao: EnvironmentFeatureFlagsDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + environmentFeatureFlagsDao = db.environmentFeatureFlagsDao() + } + + @After + fun tearDoown() { + db.close() + } + + @Test + fun testInsertReplace() = runTest { + val featureFlags = EnvironmentFeatureFlags( + userId = 1, + mapOf("feature_flag" to true) + ) + val updated = featureFlags.copy( + featureFlags = mapOf("feature_flag" to false) + ) + + environmentFeatureFlagsDao.insert(featureFlags) + + environmentFeatureFlagsDao.insert(updated) + + val result = environmentFeatureFlagsDao.findByUserId(1L) + + assertEquals(updated, result) + } + + @Test + fun testFindByUserId() = runTest { + val featureFlags = EnvironmentFeatureFlags( + userId = 1, + mapOf("feature_flag" to true) + ) + val featureFlags2 = EnvironmentFeatureFlags( + userId = 2, + mapOf("feature_flag" to false) + ) + + environmentFeatureFlagsDao.insert(featureFlags) + environmentFeatureFlagsDao.insert(featureFlags2) + + val result = environmentFeatureFlagsDao.findByUserId(1L) + + assertEquals(featureFlags, result) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/SubmissionCommentDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/SubmissionCommentDaoTest.kt index 3f143ee8c9..9b75a0be9d 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/SubmissionCommentDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/SubmissionCommentDaoTest.kt @@ -95,4 +95,26 @@ class SubmissionCommentDaoTest { Assert.assertEquals("Obi-Wan", result.author!!.displayName) Assert.assertEquals("Order 66", result.mediaComment!!.displayName) } + + @Test + fun testFindBySubmissionId() = runTest { + val submissionComment = SubmissionCommentEntity( + id = 1, + comment = "These are the droids you are looking for", + authorId = 1, + mediaCommentId = "66", + submissionId = 1 + ) + val submissionComment2 = SubmissionCommentEntity( + id = 2, + comment = "These are not the droids you are looking for", + submissionId = 2 + ) + submissionCommentDao.insertAll(listOf(submissionComment, submissionComment2)) + + val result = submissionCommentDao.findBySubmissionId(1) + + Assert.assertEquals(1, result.size) + Assert.assertEquals(submissionComment, result.first().submissionComment) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt index 311a14c31e..3d7f4675d5 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt @@ -57,4 +57,10 @@ class DatabaseModule { fun provideDashboardFileUploadDao(appDatabase: AppDatabase): DashboardFileUploadDao { return appDatabase.dashboardFileUploadDao() } + + @Provides + @Singleton + fun provideEnvironmentFeatureFlagsDao(appDatabase: AppDatabase): EnvironmentFeatureFlagsDao { + return appDatabase.environmentFeatureFlagsDao() + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt index 5836bc39de..7199dc3a53 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt @@ -16,8 +16,10 @@ */ package com.instructure.pandautils.di +import com.instructure.canvasapi2.apis.FeaturesAPI import com.instructure.canvasapi2.managers.UserManager import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.room.appdatabase.daos.EnvironmentFeatureFlagsDao import com.instructure.pandautils.utils.FeatureFlagProvider import dagger.Module import dagger.Provides @@ -29,7 +31,7 @@ import dagger.hilt.components.SingletonComponent class FeatureFlagModule { @Provides - fun provideFeatureFlagProvider(userManager: UserManager, apiPrefs: ApiPrefs): FeatureFlagProvider { - return FeatureFlagProvider(userManager, apiPrefs) + fun provideFeatureFlagProvider(userManager: UserManager, apiPrefs: ApiPrefs, featuresApi: FeaturesAPI.FeaturesInterface, environmentFeatureFlagsDao: EnvironmentFeatureFlagsDao): FeatureFlagProvider { + return FeatureFlagProvider(userManager, apiPrefs, featuresApi, environmentFeatureFlagsDao) } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt index 243e856f65..152bdae77c 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt @@ -3,9 +3,9 @@ package com.instructure.pandautils.room.appdatabase import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import com.instructure.pandautils.room.common.Converters import com.instructure.pandautils.room.appdatabase.daos.* import com.instructure.pandautils.room.appdatabase.entities.* +import com.instructure.pandautils.room.common.Converters import com.instructure.pandautils.room.common.daos.AttachmentDao import com.instructure.pandautils.room.common.daos.AuthorDao import com.instructure.pandautils.room.common.daos.MediaCommentDao @@ -19,12 +19,13 @@ import com.instructure.pandautils.room.common.entities.SubmissionCommentEntity entities = [ AttachmentEntity::class, AuthorEntity::class, + EnvironmentFeatureFlags::class, FileUploadInputEntity::class, MediaCommentEntity::class, SubmissionCommentEntity::class, PendingSubmissionCommentEntity::class, DashboardFileUploadEntity::class - ], version = 6 + ], version = 8 ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -42,4 +43,6 @@ abstract class AppDatabase : RoomDatabase() { abstract fun pendingSubmissionCommentDao(): PendingSubmissionCommentDao abstract fun dashboardFileUploadDao(): DashboardFileUploadDao + + abstract fun environmentFeatureFlagsDao(): EnvironmentFeatureFlagsDao } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt index fe79a15f37..f7cb1e1095 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt @@ -45,5 +45,13 @@ val appDatabaseMigrations = arrayOf( createMigration(5, 6) { database -> database.execSQL("ALTER TABLE AttachmentEntity ADD COLUMN submissionId INTEGER") database.execSQL("ALTER TABLE SubmissionCommentEntity ADD COLUMN submissionId INTEGER") + }, + + createMigration(6, 7) { database -> + database.execSQL("ALTER TABLE AttachmentEntity ADD COLUMN attempt INTEGER") + }, + + createMigration(7, 8) { database -> + database.execSQL("CREATE TABLE IF NOT EXISTS EnvironmentFeatureFlags (userId INTEGER NOT NULL, featureFlags TEXT NOT NULL, PRIMARY KEY(userId))") } ) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDao.kt new file mode 100644 index 0000000000..3e1739202f --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDao.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.room.appdatabase.daos + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFlags + +@Dao +interface EnvironmentFeatureFlagsDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(featureFlags: EnvironmentFeatureFlags) + + @Query("SELECT * FROM EnvironmentFeatureFlags WHERE userId = :userId") + suspend fun findByUserId(userId: Long): EnvironmentFeatureFlags? +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/EnvironmentFeatureFlags.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/EnvironmentFeatureFlags.kt new file mode 100644 index 0000000000..c6e91cb2f4 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/EnvironmentFeatureFlags.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.room.appdatabase.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class EnvironmentFeatureFlags( + @PrimaryKey + val userId: Long, + val featureFlags: Map +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/Converters.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/Converters.kt index 061e9ddb56..9afca310b8 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/Converters.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/Converters.kt @@ -1,6 +1,8 @@ package com.instructure.pandautils.room.common import androidx.room.TypeConverter +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import java.util.* class Converters { @@ -33,4 +35,14 @@ class Converters { fun longToDate(timestamp: Long?): Date? { return timestamp?.let { Date(it) } } + + @TypeConverter + fun stringToStringBooleanMap(value: String): Map { + return Gson().fromJson(value, object : TypeToken>() {}.type) + } + + @TypeConverter + fun stringBooleanMapToString(value: Map?): String { + return if(value == null) "" else Gson().toJson(value) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/AttachmentDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/AttachmentDao.kt index 8d2942bf75..54b4c5d147 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/AttachmentDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/AttachmentDao.kt @@ -6,10 +6,10 @@ import com.instructure.pandautils.room.common.entities.AttachmentEntity @Dao interface AttachmentDao { - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(attachment: AttachmentEntity) - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(attachments: List) @Delete @@ -23,4 +23,7 @@ interface AttachmentDao { @Query("SELECT * FROM AttachmentEntity WHERE workerId=:parentId") suspend fun findByParentId(parentId: String): List? + + @Query("SELECT * FROM AttachmentEntity WHERE submissionId=:submissionId") + suspend fun findBySubmissionId(submissionId: Long): List } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/SubmissionCommentDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/SubmissionCommentDao.kt index 075ce58bb8..36f42f5a32 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/SubmissionCommentDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/SubmissionCommentDao.kt @@ -7,9 +7,12 @@ import com.instructure.pandautils.room.common.model.SubmissionCommentWithAttachm @Dao interface SubmissionCommentDao { - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(submissionComment: SubmissionCommentEntity): Long + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(submissionComments: List) + @Delete suspend fun delete(submissionComment: SubmissionCommentEntity) @@ -19,4 +22,7 @@ interface SubmissionCommentDao { @Transaction @Query("SELECT * FROM SubmissionCommentEntity WHERE id=:id") suspend fun findById(id: Long): SubmissionCommentWithAttachments? + + @Query("SELECT * FROM SubmissionCommentEntity WHERE submissionId=:submissionId") + suspend fun findBySubmissionId(submissionId: Long): List } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/AttachmentEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/AttachmentEntity.kt index e3f7333dfa..3c6c72e421 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/AttachmentEntity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/AttachmentEntity.kt @@ -3,7 +3,7 @@ package com.instructure.pandautils.room.common.entities import androidx.room.Entity import androidx.room.PrimaryKey import com.instructure.canvasapi2.models.Attachment -import java.util.* +import java.util.Date @Entity data class AttachmentEntity( @@ -20,33 +20,40 @@ data class AttachmentEntity( val workerId: String? = null, //Used for Submission comments val submissionCommentId: Long? = null, - val submissionId: Long? = null + val submissionId: Long? = null, + val attempt: Long? = null ) { - constructor(attachment: Attachment, workerId: String? = null, submissionCommentId: Long? = null) : this( - attachment.id, - attachment.contentType, - attachment.filename, - attachment.displayName, - attachment.url, - attachment.thumbnailUrl, - attachment.previewUrl, - attachment.createdAt, - attachment.size, - workerId, - submissionCommentId + constructor( + attachment: Attachment, + workerId: String? = null, + submissionCommentId: Long? = null, + submissionId: Long? = null, + attempt: Long? = null + ) : this( + id = attachment.id, + contentType = attachment.contentType, + filename = attachment.filename, + displayName = attachment.displayName, + url = attachment.url, + thumbnailUrl = attachment.thumbnailUrl, + previewUrl = attachment.previewUrl, + createdAt = attachment.createdAt, + size = attachment.size, + workerId = workerId, + submissionCommentId = submissionCommentId, + submissionId = submissionId, + attempt = attempt ) - fun toApiModel(): Attachment { - return Attachment( - id, - contentType, - filename, - displayName, - url, - thumbnailUrl, - previewUrl, - createdAt, - size - ) - } -} \ No newline at end of file + fun toApiModel() = Attachment( + id = id, + contentType = contentType, + filename = filename, + displayName = displayName, + url = url, + thumbnailUrl = thumbnailUrl, + previewUrl = previewUrl, + createdAt = createdAt, + size = size + ) +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/SubmissionCommentEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/SubmissionCommentEntity.kt index 6e477b4e19..7c17c84e09 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/SubmissionCommentEntity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/SubmissionCommentEntity.kt @@ -3,7 +3,7 @@ package com.instructure.pandautils.room.common.entities import androidx.room.Entity import androidx.room.PrimaryKey import com.instructure.canvasapi2.models.SubmissionComment -import java.util.* +import java.util.Date @Entity data class SubmissionCommentEntity( @@ -17,14 +17,15 @@ data class SubmissionCommentEntity( val attemptId: Long? = null, val submissionId: Long? = null ) { - constructor(submissionComment: SubmissionComment): this( - submissionComment.id, - submissionComment.authorId, - submissionComment.authorName, - submissionComment.authorPronouns, - submissionComment.comment, - submissionComment.createdAt, - submissionComment.mediaComment?.mediaId, - submissionComment.attempt + constructor(submissionComment: SubmissionComment, submissionId: Long? = null) : this( + id = submissionComment.id, + authorId = submissionComment.authorId, + authorName = submissionComment.authorName, + authorPronouns = submissionComment.authorPronouns, + comment = submissionComment.comment, + createdAt = submissionComment.createdAt, + mediaCommentId = submissionComment.mediaComment?.mediaId, + attemptId = submissionComment.attempt, + submissionId = submissionId ) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt index 21e93b56fa..a87dda717e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt @@ -16,13 +16,19 @@ */ package com.instructure.pandautils.utils +import com.instructure.canvasapi2.apis.FeaturesAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.managers.UserManager import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.pandautils.BuildConfig +import com.instructure.pandautils.room.appdatabase.daos.EnvironmentFeatureFlagsDao +import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFlags class FeatureFlagProvider( private val userManager: UserManager, - private val apiPrefs: ApiPrefs + private val apiPrefs: ApiPrefs, + private val featuresApi: FeaturesAPI.FeaturesInterface, + private val environmentFeatureFlags: EnvironmentFeatureFlagsDao ) { suspend fun getCanvasForElementaryFlag(): Boolean { @@ -39,4 +45,16 @@ class FeatureFlagProvider( fun getDiscussionRedesignFeatureFlag(): Boolean { return BuildConfig.IS_DEBUG } + + suspend fun fetchEnvironmentFeatureFlags() { + val restParams = RestParams(isForceReadFromNetwork = false) + val featureFlags = featuresApi.getEnvironmentFeatureFlags(restParams).dataOrNull ?: return + apiPrefs.user?.id?.let { + environmentFeatureFlags.insert(EnvironmentFeatureFlags(it, featureFlags)) + } + } + + suspend fun checkEnvironmentFeatureFlag(featureFlag: String): Boolean { + return apiPrefs.user?.id?.let { environmentFeatureFlags.findByUserId(it)?.featureFlags?.get(featureFlag) == true } ?: false + } } \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/FeatureFlagProviderTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/FeatureFlagProviderTest.kt index 85a52edc43..7fe768bd2d 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/FeatureFlagProviderTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/FeatureFlagProviderTest.kt @@ -16,11 +16,15 @@ */ package com.instructure.pandautils.utils +import com.instructure.canvasapi2.apis.FeaturesAPI import com.instructure.canvasapi2.managers.UserManager import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult +import com.instructure.pandautils.room.appdatabase.daos.EnvironmentFeatureFlagsDao +import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFlags import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -28,6 +32,7 @@ import junit.framework.Assert.assertFalse import junit.framework.Assert.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -36,8 +41,10 @@ class FeatureFlagProviderTest { private val userManager: UserManager = mockk(relaxed = true) private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val featuresApi: FeaturesAPI.FeaturesInterface = mockk(relaxed = true) + private val environmentFeatureFlags: EnvironmentFeatureFlagsDao = mockk(relaxed = true) - private val featureFlagProvider = FeatureFlagProvider(userManager, apiPrefs) + private val featureFlagProvider = FeatureFlagProvider(userManager, apiPrefs, featuresApi, environmentFeatureFlags) @Before fun setUp() { @@ -116,18 +123,41 @@ class FeatureFlagProviderTest { } @Test - fun `Return false if remote config flag and feature flag is enabled but dashboard override is false`() = runBlockingTest { - // Given - every { apiPrefs.elementaryDashboardEnabledOverride } returns false - every { userManager.getSelfAsync(any()) } returns mockk { - coEvery { await() } returns DataResult.Success(User(k5User = true)) + fun `Return false if remote config flag and feature flag is enabled but dashboard override is false`() = + runBlockingTest { + // Given + every { apiPrefs.elementaryDashboardEnabledOverride } returns false + every { userManager.getSelfAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(User(k5User = true)) + } + + + // When + val canvasForElementaryFlag = featureFlagProvider.getCanvasForElementaryFlag() + + // Then + assertFalse(canvasForElementaryFlag) } + @Test + fun `Save environment feature flags`() = runTest { + val featureFlags = mapOf("feature_flag" to true) + every { apiPrefs.user } returns User(id = 1L) + coEvery { featuresApi.getEnvironmentFeatureFlags(any()) } returns DataResult.Success(featureFlags) - // When - val canvasForElementaryFlag = featureFlagProvider.getCanvasForElementaryFlag() + featureFlagProvider.fetchEnvironmentFeatureFlags() - // Then - assertFalse(canvasForElementaryFlag) + coVerify(exactly = 1) { environmentFeatureFlags.insert(EnvironmentFeatureFlags(1L, featureFlags)) } + } + + @Test + fun `Check environment feature flag`() = runTest { + val featureFlags = mapOf("feature_flag" to true, + "feature_flag_2" to false) + + coEvery { environmentFeatureFlags.findByUserId(any()) } returns EnvironmentFeatureFlags(1L, featureFlags) + + assert(featureFlagProvider.checkEnvironmentFeatureFlag("feature_flag")) + assertFalse(featureFlagProvider.checkEnvironmentFeatureFlag("feature_flag_2")) } } \ No newline at end of file From c0215d07d6575f70c883e2a0f9f1130e8dccae46 Mon Sep 17 00:00:00 2001 From: domonkosadam <53480952+domonkosadam@users.noreply.github.com> Date: Mon, 17 Jul 2023 14:47:15 +0200 Subject: [PATCH 07/61] [MBL-16893][Teacher] Tablet: Refactor Syllabus fragment split view refs: MBL-16893 affects: Teacher release note: none --- .../instructure/teacher/features/syllabus/ui/SyllabusView.kt | 2 ++ .../com/instructure/teacher/fragments/CourseBrowserFragment.kt | 2 +- .../main/java/com/instructure/teacher/router/RouteMatcher.kt | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusView.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusView.kt index 7c888d419d..58be36e568 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusView.kt @@ -42,6 +42,7 @@ import com.instructure.teacher.features.syllabus.edit.EditSyllabusFragment import com.instructure.teacher.fragments.AssignmentDetailsFragment import com.instructure.teacher.mobius.common.ui.MobiusView import com.instructure.teacher.router.RouteMatcher +import com.instructure.teacher.utils.setupBackButton import com.instructure.teacher.utils.setupMenu import com.spotify.mobius.functions.Consumer import org.greenrobot.eventbus.EventBus @@ -94,6 +95,7 @@ class SyllabusView( binding.toolbar.apply { title = context.getString(com.instructure.pandares.R.string.syllabus) subtitle = canvasContext.name + setupBackButton(activity) } binding.syllabusPager.adapter = SyllabusTabAdapter(getTabTitles()) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseBrowserFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseBrowserFragment.kt index df78509560..46971f5c11 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseBrowserFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseBrowserFragment.kt @@ -253,7 +253,7 @@ class CourseBrowserFragment : BaseSyncFragment< presenter.handleStudentViewClick() } Tab.SYLLABUS_ID -> { - RouteMatcher.route(requireContext(), Route(null, SyllabusFragment::class.java, presenter.canvasContext, presenter.canvasContext.makeBundle())) + RouteMatcher.route(requireContext(), Route(SyllabusFragment::class.java, presenter.canvasContext, presenter.canvasContext.makeBundle())) } else -> { if (tab.type == Tab.TYPE_EXTERNAL) { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/router/RouteMatcher.kt b/apps/teacher/src/main/java/com/instructure/teacher/router/RouteMatcher.kt index a098409c55..dc71ffe16f 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/router/RouteMatcher.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/router/RouteMatcher.kt @@ -126,6 +126,7 @@ object RouteMatcher : BaseRouteMatcher() { fullscreenFragments.add(ViewPdfFragment::class.java) fullscreenFragments.add(ViewHtmlFragment::class.java) fullscreenFragments.add(EditDashboardFragment::class.java) + fullscreenFragments.add(CourseBrowserFragment::class.java) // Bottom Sheet Fragments bottomSheetFragments.add(EditAssignmentDetailsFragment::class.java) From 133da574a65fff8c537a4ed76ab6a04a87f70efa Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Thu, 20 Jul 2023 21:23:13 +0200 Subject: [PATCH 08/61] [MBL-16333][Student] - Other Student E2E test extensions and stabilizations (#2070) --- .../student/ui/e2e/DiscussionsE2ETest.kt | 10 ++- .../student/ui/e2e/FilesE2ETest.kt | 39 +++++++++- .../student/ui/e2e/ModulesE2ETest.kt | 31 ++++++-- .../student/ui/e2e/PeopleE2ETest.kt | 6 +- .../ui/e2e/k5/ImportantDatesE2ETest.kt | 8 +- .../ImportantDatesInteractionTest.kt | 22 +++--- .../student/ui/pages/AnnouncementListPage.kt | 7 +- .../student/ui/pages/DiscussionListPage.kt | 39 ++++++++-- .../student/ui/pages/FileListPage.kt | 75 ++++++++++++++++--- .../student/ui/pages/GradesPage.kt | 4 - .../student/ui/pages/ImportantDatesPage.kt | 21 +++++- .../student/ui/pages/ModulesPage.kt | 18 ++++- .../student/ui/pages/NotificationPage.kt | 2 +- .../student/ui/pages/PeopleListPage.kt | 19 +++-- .../main/res/layout/fragment_file_search.xml | 1 + .../canvas/espresso/CustomMatchers.kt | 66 ++++++++++++++++ .../com/instructure/espresso/TestingUtils.kt | 22 ++++++ 17 files changed, 332 insertions(+), 58 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt index 05502815f9..891abc37ce 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt @@ -24,6 +24,7 @@ import com.instructure.dataseeding.api.DiscussionTopicsApi import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.CourseApiModel import com.instructure.espresso.ViewUtils +import com.instructure.espresso.getCurrentDateInCanvasFormat import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory @@ -70,7 +71,7 @@ class DiscussionsE2ETest: StudentTest() { Log.d(STEP_TAG,"Select course: ${course.name}.") dashboardPage.selectCourse(course) - Log.d(STEP_TAG,"Verify that the Discussions and Assignments Tabs are both displayed on the CourseBrowser Page.") + Log.d(STEP_TAG,"Verify that the Discussions and Announcements Tabs are both displayed on the CourseBrowser Page.") courseBrowserPage.assertTabDisplayed("Announcements") courseBrowserPage.assertTabDisplayed("Discussions") @@ -149,9 +150,14 @@ class DiscussionsE2ETest: StudentTest() { Log.d(STEP_TAG,"Navigate back to Discussions Page.") Espresso.pressBack() - Log.d(STEP_TAG,"Refresh the page. Assert that the previously sent reply has been counted.") + Log.d(STEP_TAG,"Refresh the page. Assert that the previously sent reply has been counted, and there are no unread replies.") discussionListPage.pullToUpdate() discussionListPage.assertReplyCount(newTopicName, 1) + discussionListPage.assertUnreadReplyCount(newTopicName, 0) + + Log.d(STEP_TAG, "Assert that the due date is the current date (in the expected format).") + val currentDate = getCurrentDateInCanvasFormat() + discussionListPage.assertDueDate(newTopicName, currentDate) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt index 7140ec0967..b7e1f53e7e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt @@ -18,7 +18,9 @@ package com.instructure.student.ui.e2e import android.os.Environment import android.util.Log +import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E +import com.instructure.canvas.espresso.refresh import com.instructure.canvasapi2.managers.DiscussionManager import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.DiscussionEntry @@ -28,13 +30,22 @@ import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.DiscussionTopicsApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.* +import com.instructure.dataseeding.model.AssignmentApiModel +import com.instructure.dataseeding.model.AttachmentApiModel +import com.instructure.dataseeding.model.CanvasUserApiModel +import com.instructure.dataseeding.model.CourseApiModel +import com.instructure.dataseeding.model.FileUploadType +import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.Randomizer import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory import com.instructure.panda_annotations.TestMetaData -import com.instructure.student.ui.utils.* +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.ViewUtils +import com.instructure.student.ui.utils.seedData +import com.instructure.student.ui.utils.tokenLogin +import com.instructure.student.ui.utils.uploadTextFile import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test import java.io.File @@ -174,6 +185,16 @@ class FilesE2ETest: StudentTest() { Log.d(STEP_TAG,"Assert that there is a directory called 'unfiled' is displayed.") fileListPage.assertItemDisplayed("unfiled") // Our discussion attachment goes under "unfiled" + Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${discussionAttachmentFile.name}', the file's name to the search input field.") + fileListPage.clickSearchButton() + fileListPage.typeSearchInput(discussionAttachmentFile.name) + + Log.d(STEP_TAG, "Assert that only 1 file matches for the search text, and it is '${discussionAttachmentFile.name}', and no directories has been shown in the result. Press search back button the quit from search result view.") + fileListPage.assertSearchResultCount(1) + fileListPage.assertItemDisplayed(discussionAttachmentFile.name) + fileListPage.assertItemNotDisplayed("unfiled") + fileListPage.pressSearchBackButton() + Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") fileListPage.selectItem("unfiled") fileListPage.assertItemDisplayed(discussionAttachmentFile.name) @@ -190,6 +211,20 @@ class FilesE2ETest: StudentTest() { Log.d(STEP_TAG,"Assert that empty view is displayed after deletion.") fileListPage.assertViewEmpty() + + Log.d(STEP_TAG, "Navigate back to global File List Page. Assert that the 'unfiled' folder has 0 items because we deleted the only item in it recently.") + Espresso.pressBack() + refresh() //TODO after this bugfix: https://instructure.atlassian.net/browse/MBL-16937?atlOrigin=eyJpIjoiNWJjODY1MTI4NDE0NGQxM2E3ZjBiYTQzZDdlM2IwOWIiLCJwIjoiaiJ9 + fileListPage.assertFolderSize("unfiled", 0) + + val testFolderName = "Krissinho's Test Folder" + Log.d(STEP_TAG, "Click on Add ('+') button and then the 'Add Folder' icon, and create a new folder with the following name: '$testFolderName'.") + fileListPage.clickAddButton() + fileListPage.clickCreateNewFolderButton() + fileListPage.createNewFolder(testFolderName) + + Log.d(STEP_TAG,"Assert that there is a folder called '$testFolderName' is displayed.") + fileListPage.assertItemDisplayed(testFolderName) } private fun commentOnSubmission( diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt index ffb0d5a829..48f911a9a0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt @@ -19,8 +19,16 @@ package com.instructure.student.ui.e2e import android.util.Log import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E -import com.instructure.dataseeding.api.* -import com.instructure.dataseeding.model.* +import com.instructure.dataseeding.api.AssignmentsApi +import com.instructure.dataseeding.api.DiscussionTopicsApi +import com.instructure.dataseeding.api.ModulesApi +import com.instructure.dataseeding.api.PagesApi +import com.instructure.dataseeding.api.QuizzesApi +import com.instructure.dataseeding.model.CanvasUserApiModel +import com.instructure.dataseeding.model.CourseApiModel +import com.instructure.dataseeding.model.ModuleApiModel +import com.instructure.dataseeding.model.ModuleItemTypes +import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 @@ -110,10 +118,10 @@ class ModulesE2ETest: StudentTest() { Espresso.pressBack() Log.d(PREPARATION_TAG,"Publish ${module1.name} module.") - updateModule(course, module1, teacher) + publishModule(course, module1, teacher) Log.d(PREPARATION_TAG,"Publish ${module2.name} module.") - updateModule(course, module2, teacher) + publishModule(course, module2, teacher) Log.d(STEP_TAG,"Refresh the page. Assert that the 'Modules' Tab is displayed.") courseBrowserPage.refresh() @@ -133,9 +141,22 @@ class ModulesE2ETest: StudentTest() { modulesPage.assertModuleItemDisplayed(module2, assignment2.name) modulesPage.assertModuleItemDisplayed(module2, page1.title) modulesPage.assertModuleItemDisplayed(module2, discussionTopic1.title) + + Log.d(STEP_TAG, "Collapse the '${module2.name}' module. Assert that there will be 4 countable items on the screen.") + modulesPage.clickOnModuleExpandCollapseIcon(module2.name) + modulesPage.assertModulesAndItemsCount(4) // 2 modules titles and 2 module item in first module + + Log.d(STEP_TAG, "Expand the '${module2.name}' module. Assert that there will be 7 countable items on the screen.") + modulesPage.clickOnModuleExpandCollapseIcon(module2.name) + modulesPage.assertModulesAndItemsCount(7) // 2 modules titles, 2 module items in first module, 3 items in second module + + Log.d(STEP_TAG, "Assert that ${assignment1.name} module item is displayed and open it. Assert that the Assignment Details page is displayed with the corresponding assignment title.") + modulesPage.assertAndClickModuleItem(module1.name, assignment1.name, true) + assignmentDetailsPage.assertPageObjects() + assignmentDetailsPage.assertAssignmentTitle(assignment1.name) } - private fun updateModule( + private fun publishModule( course: CourseApiModel, module1: ModuleApiModel, teacher: CanvasUserApiModel diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt index f5900bdbbf..b63a31b4ab 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt @@ -59,16 +59,16 @@ class PeopleE2ETest : StudentTest() { peopleListPage.assertPersonListed(teacher) peopleListPage.assertPersonListed(student1) peopleListPage.assertPersonListed(student2) - peopleListPage.assertPeopleCount(5) //2 for Teachers and Students sections, 1 for teacher user and 2 for student users. + peopleListPage.assertPeopleCount(3) Log.d(STEP_TAG,"Collapse student list and assert that the students are not displayed, but the teacher user is displayed.") peopleListPage.clickOnStudentsExpandCollapseButton() peopleListPage.assertPersonListed(teacher) - peopleListPage.assertPeopleCount(3) //2 for Teachers and Students sections, and 3rd for the teacher user. + peopleListPage.assertPeopleCount(1) peopleListPage.clickOnStudentsExpandCollapseButton() peopleListPage.assertPersonListed(student1) peopleListPage.assertPersonListed(student2) - peopleListPage.assertPeopleCount(5) //2 for Teachers and Students sections, 1 for teacher user and 2 for student users. + peopleListPage.assertPeopleCount(3) Log.d(STEP_TAG,"Select ${student2.name} student and assert if we are landing on the Person Details Page.") peopleListPage.selectPerson(student2) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt index 106a18af78..54c3387d3e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt @@ -93,8 +93,8 @@ class ImportantDatesE2ETest : StudentTest() { importantDatesPage.assertItemDisplayed(testAssignment3.name) importantDatesPage.assertItemNotDisplayed(testNotImportantAssignment.name) - Log.d(STEP_TAG, "Assert that the count of the items (5) and the day strings are correct on the Important Dates page.") - importantDatesPage.assertRecyclerViewItemCount(5) // We count both day texts and calendar events here, since both types are part of the recyclerView. + Log.d(STEP_TAG, "Assert that the count of the items (3) and the day strings are correct on the Important Dates page.") + importantDatesPage.assertRecyclerViewItemCount(3) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment1.dueAt.toDate())) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment2.dueAt.toDate())) @@ -114,8 +114,8 @@ class ImportantDatesE2ETest : StudentTest() { importantDatesPage.assertItemDisplayed(testAssignment3.name) importantDatesPage.assertItemNotDisplayed(testNotImportantAssignment.name) - Log.d(STEP_TAG, "Assert that the count of the items (5) and the day strings are correct on the Important Dates page after the refresh.") - importantDatesPage.assertRecyclerViewItemCount(5) // We count both day texts and calendar events here, since both types are part of the recyclerView. + Log.d(STEP_TAG, "Assert that the count of the items (3) and the day strings are correct on the Important Dates page after the refresh.") + importantDatesPage.assertRecyclerViewItemCount(3) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment1.dueAt.toDate())) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment2.dueAt.toDate())) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt index 46b1f194b4..91cd8158c0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt @@ -17,7 +17,11 @@ package com.instructure.student.ui.interaction import com.instructure.canvas.espresso.StubTablet -import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addAssignmentCalendarEvent +import com.instructure.canvas.espresso.mockCanvas.addCourseCalendarEvent +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow @@ -49,7 +53,7 @@ class ImportantDatesInteractionTest : StudentTest() { goToImportantDatesTab(data) importantDatesPage.assertItemDisplayed(event.title!!) - importantDatesPage.assertRecyclerViewItemCount(2) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(1) importantDatesPage.assertDayTextIsDisplayed(generateDayString(event.startDate)) } @@ -65,7 +69,7 @@ class ImportantDatesInteractionTest : StudentTest() { goToImportantDatesTab(data) importantDatesPage.assertItemDisplayed(assignment.name!!) - importantDatesPage.assertRecyclerViewItemCount(2) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(1) importantDatesPage.assertDayTextIsDisplayed(generateDayString(assignmentScheduleItem.startDate)) } @@ -91,13 +95,13 @@ class ImportantDatesInteractionTest : StudentTest() { goToImportantDatesTab(data) val eventToCheck = data.addCourseCalendarEvent(course.id, 2.days.fromNow.iso8601, "Important event 2", "Important event 2 description", true) - importantDatesPage.assertRecyclerViewItemCount(2) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(1) importantDatesPage.assertDayTextIsDisplayed(generateDayString(existedEventBeforeRefresh.startDate)) //Refresh the page and verify if the previously not displayed event will be displayed after the refresh. importantDatesPage.pullToRefresh() importantDatesPage.assertItemDisplayed(eventToCheck.title!!) - importantDatesPage.assertRecyclerViewItemCount(3) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(2) importantDatesPage.assertDayTextIsDisplayed(generateDayString(eventToCheck.startDate)) } @@ -112,7 +116,7 @@ class ImportantDatesInteractionTest : StudentTest() { goToImportantDatesTab(data) importantDatesPage.assertItemDisplayed(event.title!!) - importantDatesPage.assertRecyclerViewItemCount(2) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(1) //Opening the calendar event importantDatesPage.clickImportantDatesItem(event.title!!) @@ -133,7 +137,7 @@ class ImportantDatesInteractionTest : StudentTest() { goToImportantDatesTab(data) importantDatesPage.assertItemDisplayed(assignment.name!!) - importantDatesPage.assertRecyclerViewItemCount(2) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(1) // We count both day texts and calendar events here, since both types are part of the recyclerView. //Opening the calendar assignment event importantDatesPage.clickImportantDatesItem(assignment.name!!) @@ -161,7 +165,7 @@ class ImportantDatesInteractionTest : StudentTest() { importantDatesPage.assertItemDisplayed(it.title!!) } } - importantDatesPage.assertRecyclerViewItemCount(3) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(2) importantDatesPage.assertDayTextIsDisplayed(generateDayString(calendarEvent.startDate)) } @@ -194,7 +198,7 @@ class ImportantDatesInteractionTest : StudentTest() { importantDatesPage.assertDayTextIsDisplayed(generateDayString(twoDaysFromNowEvent.startDate)) importantDatesPage.swipeUp() // Need to do this because on landscape mode the last item cannot be seen on the view by default. importantDatesPage.assertDayTextIsDisplayed(generateDayString(threeDaysFromNowEvent.startDate)) - importantDatesPage.assertRecyclerViewItemCount(6) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(3) } private fun goToImportantDatesTab(data: MockCanvas) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt index 831b2f9fca..d56e3f34e0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt @@ -16,11 +16,14 @@ */ package com.instructure.student.ui.pages -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import com.instructure.espresso.assertDisplayed import com.instructure.espresso.matchers.WaitForViewMatcher -import com.instructure.espresso.page.* +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.withParent +import com.instructure.espresso.page.withText import com.instructure.student.R class AnnouncementListPage : BasePage(R.id.discussionListPage) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt index 86e3054fb3..5e7d5468a3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt @@ -25,11 +25,29 @@ import com.instructure.canvas.espresso.explicitClick import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.waitForMatcherWithRefreshes import com.instructure.canvasapi2.models.DiscussionTopicHeader -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertNotDisplayed +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.onViewWithText +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.waitForViewWithId +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withDescendant +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText +import com.instructure.espresso.replaceText +import com.instructure.espresso.scrollTo +import com.instructure.espresso.swipeDown +import com.instructure.espresso.waitForCheck import com.instructure.student.R import com.instructure.student.ui.utils.TypeInRCETextEditor import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.anyOf import org.hamcrest.Matchers.containsString class DiscussionListPage : BasePage(R.id.discussionListPage) { @@ -71,14 +89,20 @@ class DiscussionListPage : BasePage(R.id.discussionListPage) { fun assertReplyCount(topicTitle: String, count: Int) { val matcher = allOf( withId(R.id.readUnreadCounts), - withText(containsString("$count Repl")), // Could be "Reply" or "Replies" + withText(anyOf(containsString("$count Reply"), containsString("$count Replies"))), // Could be "Reply" or "Replies" hasSibling(allOf( withId(R.id.discussionTitle), withText(topicTitle) ))) - scrollRecyclerView(R.id.discussionRecyclerView, matcher) - onView(matcher).assertDisplayed() // probably redundant + onView(matcher).scrollTo().assertDisplayed() + } + + fun assertUnreadReplyCount(topicTitle: String, count: Int) { + val matcher = allOf(withId(R.id.readUnreadCounts), withText(containsString("$count Unread")), + hasSibling(allOf(withId(R.id.discussionTitle), withText(topicTitle)))) + + onView(matcher).scrollTo().assertDisplayed() } fun assertUnreadCount(topicTitle: String, count: Int) { @@ -176,4 +200,9 @@ class DiscussionListPage : BasePage(R.id.discussionListPage) { val ancestorMatcher = allOf(withId(R.id.discussionLayout), withDescendant(withId(R.id.discussionTitle) + withText(announcementName))) onView(allOf(withId(R.id.nestedIcon), withContentDescription(R.string.locked), withAncestor(ancestorMatcher))).assertDisplayed() } + + fun assertDueDate(topicTitle: String, expectedDateString: String) { + val matcher = allOf(withId(R.id.dueDate), withText(containsString(expectedDateString)), hasSibling(allOf(withId(R.id.discussionTitle), withText(topicTitle)))) + onView(matcher).scrollTo().assertDisplayed() + } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt index f625de7dd1..0527a77b8d 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt @@ -17,25 +17,29 @@ package com.instructure.student.ui.pages import androidx.appcompat.widget.AppCompatButton -import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso import androidx.test.espresso.action.ViewActions.swipeDown -import androidx.test.espresso.matcher.ViewMatchers.hasSibling -import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast -import androidx.test.espresso.matcher.ViewMatchers.withChild -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.clearText import com.instructure.espresso.click import com.instructure.espresso.page.BasePage -import com.instructure.espresso.page.onViewWithId +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView import com.instructure.espresso.page.waitForViewWithId +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.replaceText +import com.instructure.espresso.scrollTo import com.instructure.espresso.typeText import com.instructure.student.R import org.hamcrest.Matchers.allOf @@ -50,8 +54,12 @@ class FileListPage : BasePage(R.id.fileListPage) { fun assertItemDisplayed(itemName: String) { val matcher = allOf(withId(R.id.fileName), withText(itemName)) - scrollRecyclerView(R.id.listView, matcher) - onView(matcher).assertDisplayed() + onView(matcher).scrollTo().assertDisplayed() + } + + fun assertItemNotDisplayed(itemName: String) { + val matcher = allOf(withId(R.id.fileName), withText(itemName)) + onView(matcher).assertNotDisplayed() } fun selectItem(itemName: String) { @@ -68,6 +76,15 @@ class FileListPage : BasePage(R.id.fileListPage) { uploadFileButton.click() } + fun clickCreateNewFolderButton() { + newFolderButton.click() + } + + fun createNewFolder(folderName: String) { + waitForViewWithId(R.id.textInput).typeText(folderName) + onView(withText(R.string.ok)).click() + } + fun assertPdfPreviewDisplayed() { waitForViewWithId(R.id.pspdf__menu_option_edit_annotations).assertDisplayed() } @@ -90,6 +107,7 @@ class FileListPage : BasePage(R.id.fileListPage) { onView(withId(R.id.textInput)).clearText() onView(withId(R.id.textInput)).typeText(newName) onView(containsTextCaseInsensitive("OK")).click() + Espresso.pressBack() //Close soft keyboard refresh() } @@ -104,4 +122,39 @@ class FileListPage : BasePage(R.id.fileListPage) { // to distinguish from other emptyViews in the stack. onView(allOf(withId(R.id.emptyView), isDisplayed())).assertDisplayed() } + + fun clickSearchButton() { + onView(withId(R.id.search)).click() + } + + fun typeSearchInput(searchText: String) { + onView(withId(R.id.queryInput)).replaceText(searchText) + } + + fun clickResetSearchText() { + waitForView(withId(R.id.clearButton)).click() + onView(withId(R.id.backButton)).click() + } + + fun assertSearchResultCount(expectedCount: Int) { + Thread.sleep(2000) + onView(withId(R.id.fileSearchRecyclerView) + withAncestor(R.id.container)).check( + ViewAssertions.matches(ViewMatchers.hasChildCount(expectedCount)) + ) + } + + fun assertFileListCount(expectedCount: Int) { + Thread.sleep(2000) + onView(withId(R.id.listView) + withAncestor(R.id.container)).check( + ViewAssertions.matches(ViewMatchers.hasChildCount(expectedCount)) + ) + } + + fun pressSearchBackButton() { + onView(withId(R.id.backButton)).click() + } + + fun assertFolderSize(folderName: String, expectedSize: Int) { + onView(allOf(withId(R.id.fileSize), hasSibling(withId(R.id.fileName) + withText(folderName)))).check(matches(containsTextCaseInsensitive("$expectedSize items"))) + } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt index 5b8ac2aa3f..8f7e7d165b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt @@ -16,9 +16,7 @@ */ package com.instructure.student.ui.pages -import android.view.View import androidx.test.espresso.NoMatchingViewException -import androidx.test.espresso.PerformException import androidx.test.espresso.contrib.RecyclerViewActions import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertDisplayed @@ -30,14 +28,12 @@ import com.instructure.espresso.page.plus import com.instructure.espresso.page.scrollTo import com.instructure.espresso.page.withDescendant import com.instructure.espresso.page.withId -import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText import com.instructure.espresso.scrollTo import com.instructure.espresso.swipeDown import com.instructure.espresso.swipeUp import com.instructure.pandautils.binding.BindableViewHolder import com.instructure.student.R -import org.hamcrest.Matcher class GradesPage : BasePage(R.id.gradesPage) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ImportantDatesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ImportantDatesPage.kt index a2c47fd12b..8958de26f9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ImportantDatesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ImportantDatesPage.kt @@ -21,8 +21,21 @@ import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.ViewMatchers -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.canvas.espresso.countConstraintLayoutsInRecyclerView +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertHasChild +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText +import com.instructure.espresso.scrollTo +import com.instructure.espresso.swipeDown +import com.instructure.espresso.swipeUp import com.instructure.pandautils.binding.BindableViewHolder import com.instructure.student.R import org.hamcrest.Matcher @@ -56,7 +69,9 @@ class ImportantDatesPage : BasePage(R.id.importantDatesPage) { } fun assertRecyclerViewItemCount(expectedCount: Int) { - importantDatesRecyclerView.check(RecyclerViewItemCountAssertion(expectedCount)) + val importantDatesCount = + countConstraintLayoutsInRecyclerView(importantDatesRecyclerView) + assert(importantDatesCount == expectedCount) } fun assertDayTextIsDisplayed(dayText: String) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt index 619dfe30d6..402af186dc 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt @@ -16,7 +16,6 @@ */ package com.instructure.student.ui.pages -import androidx.test.espresso.Espresso.onView import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.PerformException import androidx.test.espresso.action.ViewActions.swipeDown @@ -29,11 +28,18 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.ModuleObject import com.instructure.dataseeding.model.ModuleApiModel +import com.instructure.espresso.RecyclerViewItemCountAssertion import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withDescendant +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText import com.instructure.espresso.scrollTo +import com.instructure.espresso.waitForCheck import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.R import org.hamcrest.Matchers.allOf @@ -102,7 +108,7 @@ class ModulesPage : BasePage(R.id.modulesPage) { } // Assert that a module item is displayed and, optionally, click it - private fun assertAndClickModuleItem(moduleName: String, itemTitle: String, clickItem: Boolean = false) { + fun assertAndClickModuleItem(moduleName: String, itemTitle: String, clickItem: Boolean = false) { try { scrollRecyclerView(R.id.listView, withText(itemTitle)) if(clickItem) { @@ -133,4 +139,12 @@ class ModulesPage : BasePage(R.id.modulesPage) { fun refresh() { onView(allOf(withId(R.id.swipeRefreshLayout),isDisplayed())).perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(5))) } + + fun clickOnModuleExpandCollapseIcon(moduleName: String) { + onView(withId(R.id.expandCollapse) + hasSibling(withChild(withText(moduleName) + withId(R.id.title)))).click() + } + + fun assertModulesAndItemsCount(expectedCount: Int) { + onView(withId(R.id.listView) + withDescendant(withId(R.id.title))).waitForCheck(RecyclerViewItemCountAssertion(expectedCount)) + } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt index 3b6c8a3b02..ff347c6f4e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt @@ -46,7 +46,7 @@ class NotificationPage : BasePage() { } fun assertHasGrade(title: String, grade: String) { - val matcher = allOf(withText(title.dropLast(1)) + hasSibling(withId(R.id.description) + withText("Grade: $grade"))) + val matcher = allOf(containsTextCaseInsensitive(title.dropLast(1)) + hasSibling(withId(R.id.description) + withText("Grade: $grade"))) onView(matcher).scrollTo().assertDisplayed() } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PeopleListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PeopleListPage.kt index 502dbf902b..5c6c163386 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PeopleListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PeopleListPage.kt @@ -18,16 +18,24 @@ package com.instructure.student.ui.pages import android.view.View import androidx.recyclerview.widget.RecyclerView -import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.hasSibling +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import com.instructure.canvas.espresso.getViewChildCountWithoutId import com.instructure.canvasapi2.models.User import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click -import com.instructure.espresso.page.* +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf @@ -58,8 +66,9 @@ class PeopleListPage: BasePage(R.id.peopleListPage) { onView(matcher).assertDisplayed() } - fun assertPeopleCount(count: Int) { - onView(withId(R.id.listView) + withAncestor(R.id.peopleListPage)).check(ViewAssertions.matches(hasChildCount(count))) + fun assertPeopleCount(expectedPeopleCount: Int) { + val peopleCount = getViewChildCountWithoutId(allOf(withId(R.id.listView) + withAncestor(R.id.peopleListPage))) + assert(peopleCount == expectedPeopleCount) } fun assertPersonListed(person: User) diff --git a/apps/student/src/main/res/layout/fragment_file_search.xml b/apps/student/src/main/res/layout/fragment_file_search.xml index ad83e1a176..d4c3143733 100644 --- a/apps/student/src/main/res/layout/fragment_file_search.xml +++ b/apps/student/src/main/res/layout/fragment_file_search.xml @@ -16,6 +16,7 @@ --> diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt index e1d532d32f..001c0a9222 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt @@ -18,13 +18,20 @@ package com.instructure.canvas.espresso import android.util.DisplayMetrics import android.view.View +import android.view.ViewGroup import android.widget.RadioButton import android.widget.TextView import androidx.appcompat.widget.Toolbar +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction import androidx.test.espresso.ViewAssertion +import androidx.test.espresso.ViewInteraction import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.BoundedMatcher +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.util.HumanReadables @@ -221,4 +228,63 @@ fun withOnlyWidthLessThan(dimInDp: Int) : BaseMatcher): Int { + var count = 0 + onView(viewMatcher).perform(object : ViewAction { + override fun getConstraints(): Matcher { + return isAssignableFrom(RecyclerView::class.java) + } + + override fun getDescription(): String { + return "Count RecyclerView children without ID" + } + + override fun perform(uiController: UiController?, view: View?) { + if (view is RecyclerView) { + val childCount = view.childCount + for (i in 0 until childCount) { + val child = view.getChildAt(i) + if (child.id == View.NO_ID) { + count++ + } + } + } + } + }) + return count +} + + +fun countConstraintLayoutsInRecyclerView(recyclerViewId: ViewInteraction): Int { + var count = 0 + recyclerViewId.perform(object : ViewAction { + override fun getConstraints(): Matcher { + return isAssignableFrom(RecyclerView::class.java) + } + + override fun getDescription(): String { + return "Counting ConstraintLayouts in RecyclerView" + } + + override fun perform(uiController: UiController, view: View) { + if (view is RecyclerView) { + count = countConstraintLayoutsInViewGroup(view) + } + } + }) + return count +} + +private fun countConstraintLayoutsInViewGroup(viewGroup: ViewGroup): Int { + var count = 0 + for (i in 0 until viewGroup.childCount) { + val child = viewGroup.getChildAt(i) + if (child is ConstraintLayout) { + count++ + } else if (child is ViewGroup) { + count += countConstraintLayoutsInViewGroup(child) + } + } + return count } \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt index d9651d75b7..254db9d454 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt @@ -15,6 +15,10 @@ */ package com.instructure.espresso +import android.os.Build +import androidx.annotation.RequiresApi +import org.apache.commons.lang3.StringUtils +import java.time.LocalDateTime import java.util.* private val RANDOM = Random() @@ -28,3 +32,21 @@ fun randomString(length: Int = 20): String = StringBuilder().apply { fun randomDouble(length: Int = 8): Double = StringBuilder().apply { repeat(length) { append(DIGITS[RANDOM.nextInt(DIGITS.length)]) } }.toString().toDouble() + +fun capitalizeFirstLetter(inputText: String): String { + return if (inputText.isNotEmpty()) { + val firstLetter = inputText.substring(0, 1).uppercase() + val restOfWord = inputText.substring(1).lowercase() + firstLetter + restOfWord + } else StringUtils.EMPTY +} + + +@RequiresApi(Build.VERSION_CODES.O) +fun getCurrentDateInCanvasFormat(): String { + val expectedDate = LocalDateTime.now() + val monthString = capitalizeFirstLetter(expectedDate.month.name.take(3)) + val dayString = expectedDate.dayOfMonth + val yearString = expectedDate.year + return "$monthString $dayString, $yearString" +} \ No newline at end of file From 16954d46f4439c1c196aff5768b8759c07ef7ced Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Fri, 21 Jul 2023 13:35:52 +0200 Subject: [PATCH 09/61] [MBL-16937][Student] Folder item size does not updated automatically refs: MBL-16937 affects: Student release note: none --- .../student/adapter/FileListRecyclerAdapter.kt | 3 +-- .../student/fragment/FileListFragment.kt | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/adapter/FileListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/FileListRecyclerAdapter.kt index 24aca23113..8897e148d8 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/FileListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/FileListRecyclerAdapter.kt @@ -25,7 +25,6 @@ import com.instructure.canvasapi2.utils.weave.WeaveJob import com.instructure.canvasapi2.utils.weave.awaitPaginated import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave -import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.fragment.FileListFragment import com.instructure.student.holders.FileViewHolder @@ -81,7 +80,7 @@ open class FileListRecyclerAdapter( apiCall = tryWeave { // Check if the folder is marked as stale (i.e. items were added/changed/removed) - val isStale = StudentPrefs.staleFolderIds.contains(folder.id) == true + val isStale = StudentPrefs.staleFolderIds.contains(folder.id) // Force network for pull-to-refresh and stale folders val forceNetwork = isRefresh || isStale diff --git a/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt index 1a99197673..fa86e5fae8 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt @@ -416,6 +416,7 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent setEmptyView(binding.emptyView, R.drawable.ic_panda_nofiles, R.string.noFiles, getNoFileSubtextId()) } StudentPrefs.staleFolderIds = StudentPrefs.staleFolderIds + folder!!.id + updateFileList() } catch { toast(R.string.errorOccurred) } @@ -440,14 +441,23 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent override fun workInfoLiveDataCallback(uuid: UUID?, workInfoLiveData: LiveData) { workInfoLiveData.observe(viewLifecycleOwner) { if (it.state == WorkInfo.State.SUCCEEDED) { - recyclerAdapter?.refresh() - folder?.let { - StudentPrefs.staleFolderIds = StudentPrefs.staleFolderIds + it.id + updateFileList(true) + folder?.let { fileFolder -> + StudentPrefs.staleFolderIds = StudentPrefs.staleFolderIds + fileFolder.id } } } } + private fun updateFileList(includeCurrentScreen: Boolean = false) { + parentFragmentManager.fragments + .filterIsInstance(FileListFragment::class.java) + .dropLast(if (includeCurrentScreen) 0 else 1) + .forEach { fragment -> + fragment.recyclerAdapter?.refresh() + } + } + private fun createFolder() { EditTextDialog.show(requireFragmentManager(), getString(R.string.createFolder), "") { name -> tryWeave { @@ -456,6 +466,7 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent } recyclerAdapter?.add(newFolder) StudentPrefs.staleFolderIds = StudentPrefs.staleFolderIds + folder!!.id + updateFileList() } catch { toast(R.string.folderCreationError) } From 615dece114b1c4f1025fe8274cd52a434cc80784 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Fri, 21 Jul 2023 15:02:12 +0200 Subject: [PATCH 10/61] [MBL16873][Student] - Extend PagesE2E test with testing editable, non-editable pages. (#2074) --- .../student/ui/e2e/PagesE2ETest.kt | 53 ++++++++++++++----- .../student/ui/pages/CanvasWebViewPage.kt | 30 ++++++++++- .../instructure/dataseeding/api/PagesApi.kt | 3 +- .../dataseeding/model/PageApiModel.kt | 8 ++- 4 files changed, 78 insertions(+), 16 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt index 44c0552ddf..1dac3ed502 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt @@ -52,43 +52,70 @@ class PagesE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seed an UNPUBLISHED page for ${course.name} course.") + Log.d(PREPARATION_TAG,"Seed an UNPUBLISHED page for '${course.name}' course.") val pageUnpublished = createCoursePage(course, teacher, published = false, frontPage = false) - Log.d(PREPARATION_TAG,"Seed a PUBLISHED page for ${course.name} course.") - val pagePublished = createCoursePage(course, teacher, published = true, frontPage = false, body = "

Regular Page Text

") + Log.d(PREPARATION_TAG,"Seed a PUBLISHED page for '${course.name}' course.") + val pagePublished = createCoursePage(course, teacher, published = true, frontPage = false, editingRoles = "teachers,students", body = "

Regular Page Text

") - Log.d(PREPARATION_TAG,"Seed a PUBLISHED, FRONT page for ${course.name} course.") - val pagePublishedFront = createCoursePage(course, teacher, published = true, frontPage = true, body = "

Front Page Text

") + Log.d(PREPARATION_TAG,"Seed a PUBLISHED, but NOT editable page for '${course.name}' course.") + val pageNotEditable = createCoursePage(course, teacher, published = true, frontPage = false, body = "

Regular Page Text

") - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(PREPARATION_TAG,"Seed a PUBLISHED, FRONT page for '${course.name}' course.") + val pagePublishedFront = createCoursePage(course, teacher, published = true, frontPage = true, editingRoles = "public", body = "

Front Page Text

") + + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Select ${course.name} course and navigate to Modules Page.") + Log.d(STEP_TAG,"Select '${course.name}' course and navigate to Modules Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectPages() - Log.d(STEP_TAG,"Assert that ${pagePublishedFront.title} published front page is displayed.") + Log.d(STEP_TAG,"Assert that '${pagePublishedFront.title}' published front page is displayed.") pageListPage.assertFrontPageDisplayed(pagePublishedFront) - Log.d(STEP_TAG,"Assert that ${pagePublished.title} published page is displayed.") + Log.d(STEP_TAG,"Assert that '${pagePublished.title}' published page is displayed.") pageListPage.assertRegularPageDisplayed(pagePublished) - Log.d(STEP_TAG,"Assert that ${pageUnpublished.title} unpublished page is NOT displayed.") + Log.d(STEP_TAG,"Assert that '${pageUnpublished.title}' unpublished page is NOT displayed.") pageListPage.assertPageNotDisplayed(pageUnpublished) - Log.d(STEP_TAG,"Open ${pagePublishedFront.title} page. Assert that it is really a front (published) page via web view assertions.") + Log.d(STEP_TAG,"Open '${pagePublishedFront.title}' page. Assert that it is really a front (published) page via web view assertions.") pageListPage.selectFrontPage(pagePublishedFront) canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Front Page Text")) Log.d(STEP_TAG,"Navigate back to Pages page.") Espresso.pressBack() - Log.d(STEP_TAG,"Open ${pagePublished.title} page. Assert that it is really a regular published page via web view assertions.") + Log.d(STEP_TAG, "Select '${pageNotEditable.title}' page. Assert that it is not editable as a student, then navigate back to Page List page.") + pageListPage.selectRegularPage(pageNotEditable) + canvasWebViewPage.assertDoesNotEditable() + Espresso.pressBack() + + Log.d(STEP_TAG,"Open '${pagePublished.title}' page. Assert that it is really a regular published page via web view assertions.") pageListPage.selectRegularPage(pagePublished) canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Regular Page Text")) + Log.d(STEP_TAG, "Click on the 'Pencil' icon and edit the body. Click on 'Save' button.") + canvasWebViewPage.clickEditPencilIcon() + canvasWebViewPage.typeInRCEEditor("

Page Text Mod

") + canvasWebViewPage.clickOnSave() + + Log.d(STEP_TAG, "Assert that the new, edited text is displayed in the page body.") + canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Page Text Mod")) + + Log.d(STEP_TAG, "Navigate back to Page List page. Select '${pagePublishedFront.title}' front page.") + Espresso.pressBack() + pageListPage.selectFrontPage(pagePublishedFront) + + Log.d(STEP_TAG, "Click on the 'Pencil' icon and edit the body. Click on 'Save' button.") + canvasWebViewPage.clickEditPencilIcon() + canvasWebViewPage.typeInRCEEditor("

Front Page Text Mod

") + canvasWebViewPage.clickOnSave() + + Log.d(STEP_TAG, "Assert that the new, edited text is displayed in the page body.") + canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Front Page Text Mod")) } private fun createCoursePage( @@ -96,11 +123,13 @@ class PagesE2ETest: StudentTest() { teacher: CanvasUserApiModel, published: Boolean, frontPage: Boolean, + editingRoles: String? = null, body: String = Randomizer.randomPageBody() ) = PagesApi.createCoursePage( courseId = course.id, published = published, frontPage = frontPage, + editingRoles = editingRoles, token = teacher.token, body = body ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CanvasWebViewPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CanvasWebViewPage.kt index ed7787bf7b..cc2a97e5eb 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CanvasWebViewPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CanvasWebViewPage.kt @@ -17,6 +17,8 @@ package com.instructure.student.ui.pages import androidx.annotation.StringRes +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches import androidx.test.espresso.web.model.Atoms.getCurrentUrl @@ -28,8 +30,17 @@ import androidx.test.espresso.web.webdriver.DriverAtoms.webScrollIntoView import androidx.test.espresso.web.webdriver.Locator import com.instructure.canvas.espresso.withElementRepeat import com.instructure.espresso.assertVisible -import com.instructure.espresso.page.* +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.onViewWithId +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText import com.instructure.student.R +import com.instructure.student.ui.utils.TypeInRCETextEditor import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.containsString @@ -93,6 +104,23 @@ open class CanvasWebViewPage : BasePage(R.id.contentWebView) { fun waitForWebView() { waitForView(allOf(withId(R.id.contentWebView), isDisplayed())) } + + fun clickEditPencilIcon() { + onView(withId(R.id.menu_edit)).click() + } + + fun assertDoesNotEditable() { + onView(withId(R.id.menu_edit)).check(doesNotExist()) + } + + fun typeInRCEEditor(textToType: String) { + waitForView(ViewMatchers.withId(R.id.rce_webView)).perform(TypeInRCETextEditor(textToType)) + } + + fun clickOnSave() { + onViewWithId(R.id.menuSavePage).click() + } + } /** data class that encapsulates info for a webview text check */ diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt index e9f11adc70..32e480f9e4 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt @@ -41,10 +41,11 @@ object PagesApi { courseId: Long, published: Boolean, frontPage: Boolean, + editingRoles: String? = null, token: String, body: String = Randomizer.randomPageBody() ): PageApiModel { - val page = CreatePageWrapper(CreatePage(Randomizer.randomPageTitle(), body, published, frontPage)) + val page = CreatePageWrapper(CreatePage(Randomizer.randomPageTitle(), body, published, frontPage, editingRoles)) return pagesService(token) .createCoursePage(courseId, page) diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/PageApiModel.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/PageApiModel.kt index 64e8b17411..11f2496a76 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/PageApiModel.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/PageApiModel.kt @@ -26,7 +26,9 @@ data class PageApiModel( val body: String, val published: Boolean, @SerializedName("front_page") - val frontPage: Boolean + val frontPage: Boolean, + @SerializedName("editing_roles") + val editingRoles: String ) data class CreatePage( @@ -34,7 +36,9 @@ data class CreatePage( val body: String, val published: Boolean, @SerializedName("front_page") - val frontPage: Boolean + val frontPage: Boolean, + @SerializedName("editing_roles") + val editingRoles: String? = null ) data class CreatePageWrapper( From 1104a8931b02786d53f5f8abc4ca51aea9567494 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Mon, 31 Jul 2023 14:47:19 +0200 Subject: [PATCH 11/61] [MBL-16890][Student] - Test folder control flow in user groups (#2077) --- .../student/ui/e2e/ShareExtensionE2ETest.kt | 17 --- .../e2e/usergroups/UserGroupFilesE2ETest.kt | 127 ++++++++++++++++++ .../interaction/GroupLinksInteractionTest.kt | 2 +- .../ShareExtensionInteractionTest.kt | 39 ------ .../student/ui/pages/CourseBrowserPage.kt | 13 +- .../student/ui/pages/DashboardPage.kt | 5 + .../student/ui/pages/FileListPage.kt | 9 +- .../student/ui/pages/GroupBrowserPage.kt | 30 +++++ .../student/ui/utils/StudentTest.kt | 102 +++++++++++++- 9 files changed, 275 insertions(+), 69 deletions(-) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/e2e/usergroups/UserGroupFilesE2ETest.kt create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/pages/GroupBrowserPage.kt diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt index b219a02e31..8df03a41d9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt @@ -19,7 +19,6 @@ package com.instructure.student.ui.e2e import android.content.Intent import android.net.Uri import android.util.Log -import androidx.core.content.FileProvider import androidx.test.espresso.Espresso import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice @@ -34,14 +33,12 @@ import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.pandautils.utils.Const import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.ViewUtils import com.instructure.student.ui.utils.seedData import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test -import java.io.File @HiltAndroidTest class ShareExtensionE2ETest: StudentTest() { @@ -226,20 +223,6 @@ class ShareExtensionE2ETest: StudentTest() { ) } - private fun setupFileOnDevice(fileName: String): Uri { - copyAssetFileToExternalCache(activityRule.activity, fileName) - - val dir = activityRule.activity.externalCacheDir - val file = File(dir?.path, fileName) - - val instrumentationContext = InstrumentationRegistry.getInstrumentation().context - return FileProvider.getUriForFile( - instrumentationContext, - "com.instructure.candroid" + Const.FILE_PROVIDER_AUTHORITY, - file - ) - } - private fun shareMultipleFiles(uris: ArrayList) { val intent = Intent().apply { action = Intent.ACTION_SEND_MULTIPLE diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/usergroups/UserGroupFilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/usergroups/UserGroupFilesE2ETest.kt new file mode 100644 index 0000000000..e9825d4c3d --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/usergroups/UserGroupFilesE2ETest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.student.ui.e2e.usergroups + +import android.util.Log +import androidx.test.espresso.Espresso +import androidx.test.espresso.intent.Intents +import com.instructure.canvas.espresso.E2E +import com.instructure.dataseeding.api.GroupsApi +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.SecondaryFeatureCategory +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.seedData +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@HiltAndroidTest +class UserGroupFilesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.E2E, secondaryFeature = SecondaryFeatureCategory.GROUPS_FILES) + fun testUserGroupFileControlFlow() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + setupFileOnDevice("samplepdf.pdf") + + Log.d(PREPARATION_TAG,"Seed some group info.") + val groupCategory = GroupsApi.createCourseGroupCategory(data.coursesList[0].id, teacher.token) + val groupCategory2 = GroupsApi.createCourseGroupCategory(data.coursesList[0].id, teacher.token) + val group = GroupsApi.createGroup(groupCategory.id, teacher.token) + val group2 = GroupsApi.createGroup(groupCategory2.id, teacher.token) + + Log.d(PREPARATION_TAG,"Create group membership for ${student.name} student.") + GroupsApi.createGroupMembership(group.id, student.id, teacher.token) + GroupsApi.createGroupMembership(group2.id, student.id, teacher.token) + + Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG,"Assert that ${group.name} groups is displayed.") + dashboardPage.assertDisplaysGroup(group, data.coursesList[0]) + dashboardPage.assertDisplaysGroup(group2, data.coursesList[0]) + + Log.d(STEP_TAG, "Select '${group.name}' group and assert if the group title is correct on the Group Browser Page.") + dashboardPage.selectGroup(group) + groupBrowserPage.assertTitleCorrect(group) + + Log.d(STEP_TAG, "Select 'Files' tab within the Group Browser Page and assert that the File List Page is displayed.") + groupBrowserPage.selectFiles() + fileListPage.assertPageObjects() + + val testFolderName = "OneWordFolder" + Log.d(STEP_TAG, "Click on Add ('+') button and then the 'Add Folder' icon, and create a new folder with the following name: '$testFolderName'.") + fileListPage.clickAddButton() + fileListPage.clickCreateNewFolderButton() + fileListPage.createNewFolder(testFolderName) + + Log.d(STEP_TAG,"Assert that there is a folder called '$testFolderName' is displayed." + + "Assert that the '$testFolderName' folder's size is 0, because we just created it.") + fileListPage.assertItemDisplayed(testFolderName) + fileListPage.assertFolderSize(testFolderName, 0) + + Log.d(STEP_TAG, "Select '$testFolderName' folder and upload a file named 'samplepdf.pdf' within it.") + fileListPage.selectItem(testFolderName) + fileListPage.clickAddButton() + fileListPage.clickUploadFileButton() + + Intents.init() + try { + stubFilePickerIntent("samplepdf.pdf") + fileUploadPage.chooseDevice() + } + finally { + Intents.release() + } + fileUploadPage.clickUpload() + + Log.d(STEP_TAG, "Assert that the file upload was successful.") + fileListPage.assertItemDisplayed("samplepdf.pdf") + + Log.d(STEP_TAG, "Navigate back to File List Page. Assert that the '$testFolderName' folder's size is 1, because we just uploaded a file in it.") + Espresso.pressBack() + fileListPage.assertFolderSize(testFolderName, 1) + + val testFolderName2 = "TwoWord Folder" + Log.d(STEP_TAG, "Click on Add ('+') button and then the 'Add Folder' icon, and create a new folder with the following name: '$testFolderName2'.") + fileListPage.clickAddButton() + fileListPage.clickCreateNewFolderButton() + fileListPage.createNewFolder(testFolderName2) + + Log.d(STEP_TAG,"Assert that there is a folder called '$testFolderName2' is displayed." + + "Assert that the '$testFolderName2' folder's size is 0, because we just created it.") + fileListPage.assertItemDisplayed(testFolderName2) + fileListPage.assertFolderSize(testFolderName2, 0) + + Log.d(STEP_TAG, "Select '$testFolderName2' folder and assert that the empty view is displayed.") + fileListPage.selectItem(testFolderName2) + fileListPage.assertViewEmpty() + } + +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt index c4a9340031..79c3f4a137 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt @@ -61,7 +61,7 @@ class GroupLinksInteractionTest : StudentTest() { fun testGroupLink_base() { setUpGroupAndSignIn() dashboardPage.selectGroup(group) - courseBrowserPage.assertTitleCorrect(group) + groupBrowserPage.assertTitleCorrect(group) } // Link to groups opens dashboard - eg: "/groups" diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt index 91e77751f1..75b100e24b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt @@ -16,13 +16,9 @@ */ package com.instructure.student.ui.interaction -import android.app.Activity -import android.app.Instrumentation import android.content.Intent import android.net.Uri -import androidx.core.content.FileProvider import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.uiautomator.UiDevice @@ -33,11 +29,9 @@ import com.instructure.canvas.espresso.mockCanvas.addAssignment import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.User -import com.instructure.pandautils.utils.Const import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest -import org.hamcrest.core.AllOf import org.junit.Test import java.io.File @@ -277,20 +271,6 @@ class ShareExtensionInteractionTest : StudentTest() { tokenLogin(MockCanvas.data.domain, token!!, student) } - private fun setupFileOnDevice(fileName: String): Uri { - copyAssetFileToExternalCache(activityRule.activity, fileName) - - val dir = activityRule.activity.externalCacheDir - val file = File(dir?.path, fileName) - - val instrumentationContext = InstrumentationRegistry.getInstrumentation().context - return FileProvider.getUriForFile( - instrumentationContext, - "com.instructure.candroid" + Const.FILE_PROVIDER_AUTHORITY, - file - ) - } - private fun shareExternalFile(uri: Uri) { val intent = Intent().apply { action = Intent.ACTION_SEND @@ -316,23 +296,4 @@ class ShareExtensionInteractionTest : StudentTest() { InstrumentationRegistry.getInstrumentation().context.startActivity(chooser) } - - private fun stubFilePickerIntent(fileName: String) { - val resultData = Intent() - val dir = activityRule.activity.externalCacheDir - val file = File(dir?.path, fileName) - val newFileUri = FileProvider.getUriForFile( - activityRule.activity, - "com.instructure.candroid" + Const.FILE_PROVIDER_AUTHORITY, - file - ) - resultData.data = newFileUri - - Intents.intending( - AllOf.allOf( - IntentMatchers.hasAction(Intent.ACTION_GET_CONTENT), - IntentMatchers.hasType("*/*"), - ) - ).respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, resultData)) - } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt index 723ce6ccce..d3a9a95924 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt @@ -27,17 +27,20 @@ import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.canvasapi2.models.Course -import com.instructure.canvasapi2.models.Group import com.instructure.canvasapi2.models.Tab import com.instructure.dataseeding.model.CourseApiModel -import com.instructure.espresso.* +import com.instructure.espresso.TextViewColorAssertion +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertHasText +import com.instructure.espresso.click import com.instructure.espresso.page.BasePage +import com.instructure.espresso.swipeUp import com.instructure.pandautils.views.SwipeRefreshLayoutAppBar import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf -class CourseBrowserPage : BasePage(R.id.courseBrowserPage) { +open class CourseBrowserPage : BasePage(R.id.courseBrowserPage) { private val initialBrowserTitle by WaitForViewWithId(R.id.courseBrowserTitle) @@ -117,10 +120,6 @@ class CourseBrowserPage : BasePage(R.id.courseBrowserPage) { onView(allOf(withId(R.id.courseBrowserTitle), isDisplayed())).assertHasText(course.originalName!!) } - fun assertTitleCorrect(group: Group) { - onView(allOf(withId(R.id.courseBrowserTitle), isDisplayed())).assertHasText(group.name!!) - } - fun assertTitleCorrect(course: CourseApiModel) { initialBrowserTitle.assertHasText(course.name) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt index 05953754ff..285f829948 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt @@ -186,6 +186,11 @@ class DashboardPage : BasePage(R.id.dashboardPage) { onView(withText(course.name) + withId(R.id.titleTextView)).click() } + fun selectGroup(group: GroupApiModel) { + val groupNameMatcher = allOf(withText(group.name), withId(R.id.groupNameView)) + onView(groupNameMatcher).scrollTo().click() + } + fun assertAnnouncementShowing(announcement: AccountNotification) { onView(withId(R.id.announcementIcon)).assertDisplayed() onView(withId(R.id.announcementTitle) + withText(announcement.subject)).assertDisplayed() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt index 0527a77b8d..a3c35dc8e9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt @@ -18,6 +18,7 @@ package com.instructure.student.ui.pages import androidx.appcompat.widget.AppCompatButton import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.swipeDown import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.assertion.ViewAssertions.matches @@ -54,7 +55,7 @@ class FileListPage : BasePage(R.id.fileListPage) { fun assertItemDisplayed(itemName: String) { val matcher = allOf(withId(R.id.fileName), withText(itemName)) - onView(matcher).scrollTo().assertDisplayed() + waitForView(matcher).scrollTo().assertDisplayed() } fun assertItemNotDisplayed(itemName: String) { @@ -69,11 +70,11 @@ class FileListPage : BasePage(R.id.fileListPage) { } fun clickAddButton() { - addButton.click() + onView(allOf(withId(R.id.addFab), isDisplayed())).perform(click()) } fun clickUploadFileButton() { - uploadFileButton.click() + onView(allOf(withId(R.id.addFileFab), isDisplayed())).perform(click()) } fun clickCreateNewFolderButton() { @@ -155,6 +156,6 @@ class FileListPage : BasePage(R.id.fileListPage) { } fun assertFolderSize(folderName: String, expectedSize: Int) { - onView(allOf(withId(R.id.fileSize), hasSibling(withId(R.id.fileName) + withText(folderName)))).check(matches(containsTextCaseInsensitive("$expectedSize items"))) + onView(allOf(withId(R.id.fileSize), hasSibling(withId(R.id.fileName) + withText(folderName)))).check(matches(containsTextCaseInsensitive("$expectedSize ${if (expectedSize == 1) "item" else "items"}"))) } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GroupBrowserPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GroupBrowserPage.kt new file mode 100644 index 0000000000..3ea8d62fc5 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GroupBrowserPage.kt @@ -0,0 +1,30 @@ +package com.instructure.student.ui.pages + +import androidx.test.espresso.Espresso +import androidx.test.espresso.matcher.ViewMatchers +import com.instructure.canvasapi2.models.Group +import com.instructure.dataseeding.model.GroupApiModel +import com.instructure.espresso.assertHasText +import com.instructure.student.R +import org.hamcrest.Matchers + +class GroupBrowserPage : CourseBrowserPage() { + + fun assertTitleCorrect(group: Group) { + Espresso.onView( + Matchers.allOf( + ViewMatchers.withId(R.id.courseBrowserTitle), + ViewMatchers.isDisplayed() + ) + ).assertHasText(group.name!!) + } + + fun assertTitleCorrect(group: GroupApiModel) { + Espresso.onView( + Matchers.allOf( + ViewMatchers.withId(R.id.courseBrowserTitle), + ViewMatchers.isDisplayed() + ) + ).assertHasText(group.name!!) + } +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt index 060c624c4b..ad59621f01 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt @@ -17,25 +17,90 @@ package com.instructure.student.ui.utils import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import android.net.Uri import android.os.Environment import android.util.Log import android.view.View +import androidx.core.content.FileProvider import androidx.hilt.work.HiltWorkerFactory import androidx.test.espresso.Espresso import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry import com.instructure.canvas.espresso.CanvasTest import com.instructure.espresso.InstructureActivityTestRule import com.instructure.espresso.swipeRight +import com.instructure.pandautils.utils.Const import com.instructure.student.BuildConfig import com.instructure.student.R import com.instructure.student.activity.LoginActivity import com.instructure.student.espresso.StudentHiltTestApplication_Application -import com.instructure.student.ui.pages.* +import com.instructure.student.ui.pages.AboutPage +import com.instructure.student.ui.pages.AnnotationCommentListPage +import com.instructure.student.ui.pages.AnnouncementListPage +import com.instructure.student.ui.pages.AssignmentDetailsPage +import com.instructure.student.ui.pages.AssignmentListPage +import com.instructure.student.ui.pages.BookmarkPage +import com.instructure.student.ui.pages.CalendarEventPage +import com.instructure.student.ui.pages.CanvasWebViewPage +import com.instructure.student.ui.pages.ConferenceDetailsPage +import com.instructure.student.ui.pages.ConferenceListPage +import com.instructure.student.ui.pages.CourseBrowserPage +import com.instructure.student.ui.pages.CourseGradesPage +import com.instructure.student.ui.pages.DashboardPage +import com.instructure.student.ui.pages.DiscussionDetailsPage +import com.instructure.student.ui.pages.DiscussionListPage +import com.instructure.student.ui.pages.EditDashboardPage +import com.instructure.student.ui.pages.ElementaryCoursePage +import com.instructure.student.ui.pages.ElementaryDashboardPage +import com.instructure.student.ui.pages.FileListPage +import com.instructure.student.ui.pages.FileUploadPage +import com.instructure.student.ui.pages.GradesPage +import com.instructure.student.ui.pages.GroupBrowserPage +import com.instructure.student.ui.pages.HelpPage +import com.instructure.student.ui.pages.HomeroomPage +import com.instructure.student.ui.pages.ImportantDatesPage +import com.instructure.student.ui.pages.InboxConversationPage +import com.instructure.student.ui.pages.InboxPage +import com.instructure.student.ui.pages.LeftSideNavigationDrawerPage +import com.instructure.student.ui.pages.LegalPage +import com.instructure.student.ui.pages.LoginFindSchoolPage +import com.instructure.student.ui.pages.LoginLandingPage +import com.instructure.student.ui.pages.LoginSignInPage +import com.instructure.student.ui.pages.ModuleProgressionPage +import com.instructure.student.ui.pages.ModulesPage +import com.instructure.student.ui.pages.NewMessagePage +import com.instructure.student.ui.pages.NotificationPage +import com.instructure.student.ui.pages.PageListPage +import com.instructure.student.ui.pages.PairObserverPage +import com.instructure.student.ui.pages.PandaAvatarPage +import com.instructure.student.ui.pages.PeopleListPage +import com.instructure.student.ui.pages.PersonDetailsPage +import com.instructure.student.ui.pages.PickerSubmissionUploadPage +import com.instructure.student.ui.pages.ProfileSettingsPage +import com.instructure.student.ui.pages.QRLoginPage +import com.instructure.student.ui.pages.QuizListPage +import com.instructure.student.ui.pages.QuizTakingPage +import com.instructure.student.ui.pages.RemoteConfigSettingsPage +import com.instructure.student.ui.pages.ResourcesPage +import com.instructure.student.ui.pages.SchedulePage +import com.instructure.student.ui.pages.SettingsPage +import com.instructure.student.ui.pages.ShareExtensionStatusPage +import com.instructure.student.ui.pages.ShareExtensionTargetPage +import com.instructure.student.ui.pages.SubmissionDetailsPage +import com.instructure.student.ui.pages.SyllabusPage +import com.instructure.student.ui.pages.TextSubmissionUploadPage +import com.instructure.student.ui.pages.TodoPage +import com.instructure.student.ui.pages.UrlSubmissionUploadPage import dagger.hilt.android.testing.HiltAndroidRule import instructure.rceditor.RCETextEditor import org.hamcrest.Matcher +import org.hamcrest.core.AllOf import org.junit.Before import org.junit.Rule import java.io.File @@ -84,6 +149,7 @@ abstract class StudentTest : CanvasTest() { val calendarEventPage = CalendarEventPage() val canvasWebViewPage = CanvasWebViewPage() val courseBrowserPage = CourseBrowserPage() + val groupBrowserPage = GroupBrowserPage() val conferenceListPage = ConferenceListPage() val conferenceDetailsPage = ConferenceDetailsPage() val elementaryCoursePage = ElementaryCoursePage() @@ -149,6 +215,40 @@ abstract class StudentTest : CanvasTest() { return 0 } } + + fun setupFileOnDevice(fileName: String): Uri { + File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "file_upload").deleteRecursively() + copyAssetFileToExternalCache(activityRule.activity, fileName) + + val dir = activityRule.activity.externalCacheDir + val file = File(dir?.path, fileName) + + val instrumentationContext = InstrumentationRegistry.getInstrumentation().context + return FileProvider.getUriForFile( + instrumentationContext, + "com.instructure.candroid" + Const.FILE_PROVIDER_AUTHORITY, + file + ) + } + + fun stubFilePickerIntent(fileName: String) { + val resultData = Intent() + val dir = activityRule.activity.externalCacheDir + val file = File(dir?.path, fileName) + val newFileUri = FileProvider.getUriForFile( + activityRule.activity, + "com.instructure.candroid" + Const.FILE_PROVIDER_AUTHORITY, + file + ) + resultData.data = newFileUri + + Intents.intending( + AllOf.allOf( + IntentMatchers.hasAction(Intent.ACTION_GET_CONTENT), + IntentMatchers.hasType("*/*"), + ) + ).respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, resultData)) + } } /* From 9945acce5ce424aa954b1ca5cad58a5e63b75c17 Mon Sep 17 00:00:00 2001 From: domonkosadam <53480952+domonkosadam@users.noreply.github.com> Date: Mon, 31 Jul 2023 15:38:28 +0200 Subject: [PATCH 12/61] [MBL-16688][Student][Teacher] Implement render test for left side menu (#2062) --- .../NavigationDrawerInteractionTest.kt | 81 ++++++++++++++----- .../ui/pages/LeftSideNavigationDrawerPage.kt | 63 ++++++++++++++- 2 files changed, 119 insertions(+), 25 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NavigationDrawerInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NavigationDrawerInteractionTest.kt index bcee97e2af..d79fbf11f1 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NavigationDrawerInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NavigationDrawerInteractionTest.kt @@ -37,6 +37,7 @@ import com.instructure.panda_annotations.TestMetaData import com.instructure.student.R import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.tokenLogin +import com.instructure.student.ui.utils.tokenLoginElementary import dagger.hilt.android.testing.HiltAndroidTest import org.hamcrest.CoreMatchers import org.junit.Before @@ -122,28 +123,6 @@ class NavigationDrawerInteractionTest : StudentTest() { loginLandingPage.assertPageObjects() } - /** - * Create two mocked students, sign in the first one, end up on the dashboard page - */ - private fun signInStudent() : MockCanvas { - val data = MockCanvas.init( - studentCount = 2, - courseCount = 1, - favoriteCourseCount = 1 - ) - - student1 = data.students.first() - student2 = data.students.last() - - course = data.courses.values.first() - - val token = data.tokenFor(student1)!! - tokenLogin(data.domain, token, student1) - dashboardPage.waitForRender() - - return data - } - // Should open a dialog and send a question for the selected course // (Checks to see that we can fill out the question and the SEND button exists.) @Test @@ -261,4 +240,62 @@ class NavigationDrawerInteractionTest : StudentTest() { Intents.release() } } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.SETTINGS, TestCategory.INTERACTION, false) + fun testMenuItemForDefaultStudent() { + signInStudent() + + leftSideNavigationDrawerPage.assertMenuItems(false) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.SETTINGS, TestCategory.INTERACTION, false) + fun testMenuItemForElementaryStudent() { + signInElementaryStudent() + + leftSideNavigationDrawerPage.assertMenuItems(true) + } + + /** + * Create two mocked students, sign in the first one, end up on the dashboard page + */ + private fun signInStudent(courseCount: Int = 1, studentCount: Int = 2, favoriteCourseCount: Int = 1) : MockCanvas { + val data = MockCanvas.init( + studentCount = studentCount, + courseCount = courseCount, + favoriteCourseCount = favoriteCourseCount + ) + + student1 = data.students.first() + student2 = data.students.last() + + course = data.courses.values.first() + + val token = data.tokenFor(student1)!! + tokenLogin(data.domain, token, student1) + dashboardPage.waitForRender() + + return data + } + + private fun signInElementaryStudent( + courseCount: Int = 1, + pastCourseCount: Int = 0, + favoriteCourseCount: Int = 0, + announcementCount: Int = 0): MockCanvas { + + val data = MockCanvas.init( + studentCount = 1, + courseCount = courseCount, + pastCourseCount = pastCourseCount, + favoriteCourseCount = favoriteCourseCount, + accountNotificationCount = announcementCount) + + val student = data.students[0] + val token = data.tokenFor(student)!! + tokenLoginElementary(data.domain, token, student) + elementaryDashboardPage.waitForRender() + return data + } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt index bcd295b004..b0680bbb85 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt @@ -14,6 +14,7 @@ import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.espresso.OnViewWithContentDescription import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView @@ -27,13 +28,26 @@ import org.hamcrest.Matcher class LeftSideNavigationDrawerPage: BasePage() { - private val settings by OnViewWithId(R.id.navigationDrawerSettings) + private val hamburgerButton by OnViewWithContentDescription(R.string.navigation_drawer_open) + + // User data + private val profileImage by OnViewWithId(R.id.navigationDrawerProfileImage) private val userName by OnViewWithId(R.id.navigationDrawerUserName) private val userEmail by OnViewWithId(R.id.navigationDrawerUserEmail) + + // Navigation items + private val files by OnViewWithId(R.id.navigationDrawerItem_files) + private val bookmarks by OnViewWithId(R.id.navigationDrawerItem_bookmarks) + private val settings by OnViewWithId(R.id.navigationDrawerSettings) + + //Option items + private val showGrades by OnViewWithId(R.id.navigationDrawerItem_showGrades) + private val colorOverlay by OnViewWithId(R.id.navigationDrawerItem_colorOverlay) + + // Account items + private val help by OnViewWithId(R.id.navigationDrawerItem_help) private val changeUser by OnViewWithId(R.id.navigationDrawerItem_changeUser) private val logoutButton by OnViewWithId(R.id.navigationDrawerItem_logout) - private val version by OnViewWithId(R.id.navigationDrawerVersion) - private val hamburgerButton by OnViewWithContentDescription(R.string.navigation_drawer_open) // Sometimes when we navigate back to the dashboard page, there can be several hamburger buttons // in the UI stack. We want to choose the one that is displayed. @@ -112,6 +126,49 @@ class LeftSideNavigationDrawerPage: BasePage() { Espresso.pressBack() } + fun assertMenuItems(isElementaryStudent: Boolean) { + hamburgerButton.click() + userName.assertDisplayed() + userEmail.assertDisplayed() + + settings.assertDisplayed() + changeUser.assertDisplayed() + logoutButton.assertDisplayed() + + if (isElementaryStudent) { + assertElementaryNavigationBehaviorMenuItems() + } + else { + assertDefaultNavigationBehaviorMenuItems() + } + } + + private fun assertDefaultNavigationBehaviorMenuItems() { + files.assertDisplayed() + bookmarks.assertDisplayed() + settings.assertDisplayed() + + showGrades.assertDisplayed() + colorOverlay.assertDisplayed() + + help.assertDisplayed() + changeUser.assertDisplayed() + logoutButton.assertDisplayed() + } + + private fun assertElementaryNavigationBehaviorMenuItems() { + files.assertDisplayed() + bookmarks.assertNotDisplayed() + settings.assertDisplayed() + + showGrades.assertNotDisplayed() + colorOverlay.assertNotDisplayed() + + help.assertDisplayed() + changeUser.assertDisplayed() + logoutButton.assertDisplayed() + } + /** * Custom ViewAction to set a SwitchCompat to the desired on/off position * [position]: true -> "on", false -> "off" From 3a2ab9b7c2ac91e2a9d950a2c22b8fd041dc01c5 Mon Sep 17 00:00:00 2001 From: domonkosadam <53480952+domonkosadam@users.noreply.github.com> Date: Tue, 1 Aug 2023 15:07:50 +0200 Subject: [PATCH 13/61] [MBL-16863][Student][Teacher][Parent] Fix code coverage refs: MBL-16863 affects: All release note: none --- gradle/jacoco.gradle | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index 37cbaf25b6..27a98927ff 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -177,11 +177,11 @@ task jacocoFullReport(type: JacocoReport, group: 'Coverage reports') { final source = files(projects.jacocoReport.sourceDirectories) - additionalSourceDirs.setFrom source - sourceDirectories.setFrom source + additionalSourceDirs.setFrom(source) + sourceDirectories.setFrom (source) - classDirectories.setFrom files(projects.jacocoReport.classDirectories) - executionData.setFrom files(projects.jacocoReport.executionData) + classDirectories.setFrom(files(projects.jacocoReport.classDirectories)) + executionData.setFrom(files(projects.jacocoReport.executionData)) reports { html { @@ -193,10 +193,6 @@ task jacocoFullReport(type: JacocoReport, group: 'Coverage reports') { destination file('build/reports/jacoco/jacocoFullReport.csv') } } - - doFirst { - executionData.setFrom files(executionData.findAll { it.exists() }) - } } task jacocoFullCombinedReport(type: JacocoReport, group: 'Coverage reports') { @@ -229,8 +225,4 @@ task jacocoFullCombinedReport(type: JacocoReport, group: 'Coverage reports') { destination file('build/reports/jacoco/jacocoFullCombinedReport.csv') } } - - doFirst { - executionData.setFrom files(executionData.findAll { it.exists() }) - } } \ No newline at end of file From a2fcddf34b4243fc361b6db6ebccf4aae2daa5ff Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Wed, 2 Aug 2023 12:48:26 +0200 Subject: [PATCH 14/61] [mbl-16818][Teacher] - Extend assignment E2E test with quiz assignment and with publish/unpublish assetions on the assignment list page. (#2082) --- .../teacher/ui/e2e/AssignmentE2ETest.kt | 41 +++++++++++++++++-- .../teacher/ui/pages/AssignmentListPage.kt | 26 ++++++++++++ .../espresso/page/PageExtensions.kt | 2 + 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt index 7bc6fa79a1..82593033cd 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt @@ -86,6 +86,15 @@ class AssignmentE2ETest : TeacherTest() { pointsPossible = 15.0 ) + Log.d(PREPARATION_TAG,"Seeding 'Quiz' assignment for ${course.name} course.") + val quizAssignment = seedAssignments( + courseId = course.id, + dueAt = 1.days.fromNow.iso8601, + submissionTypes = listOf(SubmissionType.ONLINE_QUIZ), + teacherToken = teacher.token, + pointsPossible = 15.0 + ) + Log.d(STEP_TAG,"Refresh Assignment List Page and assert that the previously seeded ${assignment[0].name} assignment has been displayed." + "Assert that the needs grading count under the corresponding assignment is 1.") assignmentListPage.refresh() @@ -102,15 +111,41 @@ class AssignmentE2ETest : TeacherTest() { editAssignmentDetailsPage.clickPublishSwitch() editAssignmentDetailsPage.saveAssignment() - Log.d(STEP_TAG,"Refresh the page. Assert that ${assignment[0].name} assignment has been published.") - assignmentDetailsPage.refresh() + Log.d(STEP_TAG,"Assert that the '${assignment[0].name}' assignment has been published.") assignmentDetailsPage.waitForRender() assignmentDetailsPage.assertPublishedStatus(false) - Log.d(STEP_TAG,"Open Edit Page and re-publish the assignment, then click on Save.") + Log.d(STEP_TAG,"Open Edit Page and re-publish the assignment, then click on Save. Assert that the assignment is published automatically, without refresh.") assignmentDetailsPage.openEditPage() editAssignmentDetailsPage.clickPublishSwitch() editAssignmentDetailsPage.saveAssignment() + assignmentDetailsPage.assertPublishedStatus(true) + + Log.d(STEP_TAG,"Navigate back to Assignment List page. Open edit quiz page and publish ${quizAssignment[0].name} quiz assignment. Click on Save.") + Espresso.pressBack() + assignmentListPage.clickAssignment(quizAssignment[0]) + quizDetailsPage.openEditPage() + editAssignmentDetailsPage.clickPublishSwitch() + editAssignmentDetailsPage.saveAssignment() + quizDetailsPage.assertQuizUnpublished() + + Log.d(STEP_TAG, "Navigate back to Assignment List page. Assert that the '${quizAssignment[0].name}' quiz displays as UNPUBLISHED. Open the quiz assignment again.") + Espresso.pressBack() + assignmentListPage.assertAssignmentUnPublished(quizAssignment[0].name) + assignmentListPage.clickAssignment(quizAssignment[0]) + + Log.d(STEP_TAG, "Open Edit Page and re-publish the assignment, then click on Save. Assert that the quiz assignment is published automatically.") + quizDetailsPage.openEditPage() + editAssignmentDetailsPage.clickPublishSwitch() + editAssignmentDetailsPage.saveAssignment() + quizDetailsPage.assertQuizPublished() + + Log.d(STEP_TAG, "Navigate back to Assignment List page. Assert that the '${quizAssignment[0].name}' quiz displays as PUBLISHED.") + Espresso.pressBack() + assignmentListPage.assertAssignmentPublished(quizAssignment[0].name) + + Log.d(STEP_TAG, "Open the '${assignment[0].name}' assignment.") + assignmentListPage.clickAssignment(assignment[0]) Log.d(PREPARATION_TAG,"Seed a submission for ${student.name} student.") seedAssignmentSubmission( diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt index 52738f29b0..99943b489e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt @@ -18,6 +18,8 @@ package com.instructure.teacher.ui.pages import androidx.test.espresso.action.ViewActions import androidx.test.espresso.matcher.ViewMatchers.hasSibling +import androidx.test.espresso.matcher.ViewMatchers.withChild +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import com.instructure.canvasapi2.models.Assignment import com.instructure.dataseeding.model.AssignmentApiModel import com.instructure.espresso.OnViewWithId @@ -30,10 +32,12 @@ import com.instructure.espresso.page.onView import com.instructure.espresso.page.plus import com.instructure.espresso.page.waitForViewWithText import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText import com.instructure.espresso.swipeDown import com.instructure.espresso.waitForCheck import com.instructure.teacher.R +import org.hamcrest.CoreMatchers.allOf /** * AssignmentListPage represents a page that displays a list of assignments. @@ -145,4 +149,26 @@ class AssignmentListPage : BasePage() { fun assertNeedsGradingCountOfAssignment(assignmentName: String, needsGradingCount: Int) { onView(withId(R.id.ungradedCount) + withText("$needsGradingCount needs grading") + hasSibling(withId(R.id.assignmentTitle) + withText(assignmentName))).assertDisplayed() } + + /** + * Asserts that the given assignment status is published (so the published icon is displayed). + * + * @param assignmentName The name of the assignment to check. + */ + fun assertAssignmentPublished(assignmentName: String) { + onView(allOf(withId(R.id.publishedStatusIcon), withContentDescription(R.string.published), + withParent(hasSibling(withChild(withText(assignmentName) + withId(R.id.assignmentTitle)) + )))).assertDisplayed() + } + + /** + * Asserts that the given assignment status is unpublished (so the unpublished icon is displayed). + * + * @param assignmentName The name of the assignment to check. + */ + fun assertAssignmentUnPublished(assignmentName: String) { + onView(allOf(withId(R.id.publishedStatusIcon), withContentDescription(R.string.not_published), + withParent(hasSibling(withChild(withText(assignmentName) + withId(R.id.assignmentTitle)) + )))).assertDisplayed() + } } diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt index b0c51d9431..cae3a84948 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt @@ -42,6 +42,8 @@ fun BasePage.withId(id: Int): Matcher = ViewMatchers.withId(id) fun BasePage.withParent(id: Int): Matcher = ViewMatchers.withParent(withId(id)) +fun BasePage.withParent(matcher: Matcher): Matcher = ViewMatchers.withParent(matcher) + fun BasePage.withAncestor(id: Int): Matcher = ViewMatchers.isDescendantOfA(withId(id)) fun BasePage.withAncestor(matcher: Matcher): Matcher = ViewMatchers.isDescendantOfA(matcher) From 3b138919f42f06d96fa283ba770863dfce2ca4f3 Mon Sep 17 00:00:00 2001 From: domonkosadam <53480952+domonkosadam@users.noreply.github.com> Date: Wed, 2 Aug 2023 13:49:22 +0200 Subject: [PATCH 15/61] [MBL-16938][Student] Error occurs when clicking on Launch External Tool refs: MBL-16938 affects: Student release note: none --- .../student/fragment/LtiLaunchFragment.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/fragment/LtiLaunchFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/LtiLaunchFragment.kt index ba280fcbf8..3100f10a62 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/LtiLaunchFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/LtiLaunchFragment.kt @@ -103,10 +103,18 @@ class LtiLaunchFragment : ParentFragment() { when { sessionLessLaunch -> { // This is specific for Studio and Gauge - url = when (canvasContext) { - is Course -> "${ApiPrefs.fullDomain}/api/v1/courses/${canvasContext.id}/external_tools/sessionless_launch?url=$url" - is Group -> "${ApiPrefs.fullDomain}/api/v1/groups/${canvasContext.id}/external_tools/sessionless_launch?url=$url" - else -> "${ApiPrefs.fullDomain}/api/v1/accounts/self/external_tools/sessionless_launch?url=$url" + val id = url.substringAfterLast("/external_tools/").substringBefore("?") + url = when { + (id.toIntOrNull() != null) -> when (canvasContext) { + is Course -> "${ApiPrefs.fullDomain}/api/v1/courses/${canvasContext.id}/external_tools/sessionless_launch?id=$id" + is Group -> "${ApiPrefs.fullDomain}/api/v1/groups/${canvasContext.id}/external_tools/sessionless_launch?id=$id" + else -> "${ApiPrefs.fullDomain}/api/v1/accounts/self/external_tools/sessionless_launch?id=$id" + } + else -> when (canvasContext) { + is Course -> "${ApiPrefs.fullDomain}/api/v1/courses/${canvasContext.id}/external_tools/sessionless_launch?url=$url" + is Group -> "${ApiPrefs.fullDomain}/api/v1/groups/${canvasContext.id}/external_tools/sessionless_launch?url=$url" + else -> "${ApiPrefs.fullDomain}/api/v1/accounts/self/external_tools/sessionless_launch?url=$url" + } } loadSessionlessLtiUrl(url) } From a0fca8b3afb0cd12aca3a6246f98b8cafb5a6798 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Wed, 2 Aug 2023 13:50:18 +0200 Subject: [PATCH 16/61] [MBL-16923][Student] Dashboard letter grade only (#2081) refs: MBL-16923 affects: Student release note: Implemented letter grade only setting for course * Dashboard letter grade only. * Fixed pact test. * Integration tests for letter grade. * stub test --- .../interaction/DashboardInteractionTest.kt | 109 +++++++++++++----- .../interaction/InAppUpdateInteractionTest.kt | 3 +- .../student/ui/pages/DashboardPage.kt | 4 + .../student/holders/CourseViewHolder.kt | 25 +++- .../instructure/canvasapi2/apis/CourseAPI.kt | 4 +- .../instructure/canvasapi2/models/Course.kt | 4 +- .../canvasapi2/models/CourseSettings.kt | 9 +- .../pact/canvas/apis/CoursesApiPactTests.kt | 2 +- 8 files changed, 120 insertions(+), 40 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt index 7ecf500d36..56568c7fa7 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt @@ -21,6 +21,8 @@ import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAccountNotification import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.models.CourseSettings +import com.instructure.canvasapi2.models.Grades import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory @@ -39,7 +41,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testNavigateToDashboard() { // User should be able to tap and navigate to dashboard page - val data = getToDashboard(courseCount = 1, favoriteCourseCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + goToDashboard(data) dashboardPage.clickInboxTab() inboxPage.goToDashboard() dashboardPage.assertDisplaysCourse(data.courses.values.first()) // disambiguates via isDisplayed() @@ -54,7 +57,8 @@ class DashboardInteractionTest : StudentTest() { fun testDashboardCourses_emptyState() { // Empty state should be displayed with a 'Add Courses' button, when nothing is favorited (and courses are completed/concluded) // With the new DashboardCard api being used, if nothing is a favorite it will default to active enrollments - getToDashboard(courseCount = 0, pastCourseCount = 1) + val data = setUpData(courseCount = 0, pastCourseCount = 1) + goToDashboard(data) dashboardPage.assertDisplaysAddCourseMessage() } @@ -63,7 +67,8 @@ class DashboardInteractionTest : StudentTest() { fun testDashboardCourses_addFavorite() { // Starring should add course to favorite list - val data = getToDashboard(courseCount = 2, favoriteCourseCount = 1) + val data = setUpData(courseCount = 2, favoriteCourseCount = 1) + goToDashboard(data) val nonFavorite = data.courses.values.filter { x -> !x.isFavorite }.first() dashboardPage.assertCourseNotShown(nonFavorite) @@ -84,7 +89,8 @@ class DashboardInteractionTest : StudentTest() { fun testDashboardCourses_removeFavorite() { // Un-starring should remove course from favorite list - val data = getToDashboard(courseCount = 2, favoriteCourseCount = 2) + val data = setUpData(courseCount = 2, favoriteCourseCount = 2) + goToDashboard(data) val favorite = data.courses.values.filter { x -> x.isFavorite }.first() dashboardPage.assertDisplaysCourse(favorite) @@ -105,7 +111,8 @@ class DashboardInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardCourses_addAllToFavorites() { - val data = getToDashboard(courseCount = 3, favoriteCourseCount = 0) + val data = setUpData(courseCount = 3, favoriteCourseCount = 0) + goToDashboard(data) val toFavorite = data.courses.values data.courses.values.forEach { dashboardPage.assertDisplaysCourse(it) } @@ -123,7 +130,8 @@ class DashboardInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardCourses_removeAllFromFavorites() { - val data = getToDashboard(courseCount = 3, favoriteCourseCount = 2) + val data = setUpData(courseCount = 3, favoriteCourseCount = 2) + goToDashboard(data) val toRemove = data.courses.values.filter { it.isFavorite } toRemove.forEach { dashboardPage.assertDisplaysCourse(it) } @@ -142,7 +150,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardAnnouncement_refresh() { // Pull to refresh loads new announcements - val data = getToDashboard(courseCount = 1, favoriteCourseCount = 1) // No announcements initially + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) // No announcements initially + goToDashboard(data) dashboardPage.assertAnnouncementsGone() val announcement = data.addAccountNotification() dashboardPage.refresh() @@ -153,7 +162,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardAnnouncement_dismiss() { // Tapping dismiss should remove the announcement. Refresh should not display it again. - val data = getToDashboard(courseCount = 1, favoriteCourseCount = 1, announcementCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1, announcementCount = 1) + goToDashboard(data) val announcement = data.accountNotifications.values.first() dashboardPage.assertAnnouncementShowing(announcement) @@ -166,7 +176,8 @@ class DashboardInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardInvite_accept() { - val data = getToDashboard(courseCount = 2, invitedCourseCount = 1) + val data = setUpData(courseCount = 2, invitedCourseCount = 1) + goToDashboard(data) val invitedCourse = data.courses.values.first { it.enrollments?.any { it.enrollmentState == EnrollmentAPI.STATE_INVITED } ?: false } dashboardPage.assertInviteShowing(invitedCourse.name) @@ -182,7 +193,8 @@ class DashboardInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardInvite_decline() { - val data = getToDashboard(courseCount = 2, invitedCourseCount = 1) + val data = setUpData(courseCount = 2, invitedCourseCount = 1) + goToDashboard(data) val invitedCourse = data.courses.values.first { it.enrollments?.any { it.enrollmentState == EnrollmentAPI.STATE_INVITED } ?: false } dashboardPage.assertInviteShowing(invitedCourse.name) @@ -199,7 +211,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardAnnouncement_view() { // Tapping global announcement displays the content - val data = getToDashboard(courseCount = 1, favoriteCourseCount = 1, announcementCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1, announcementCount = 1) + goToDashboard(data) val announcement = data.accountNotifications.values.first() dashboardPage.assertAnnouncementShowing(announcement) @@ -213,7 +226,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.INTERACTION, false) fun testDashboardCourses_tappingCourseCardDisplaysCourseBrowser() { // Tapping on a course card opens course browser page - val data = getToDashboard(courseCount = 1, favoriteCourseCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + goToDashboard(data) val course = data.courses.values.first() dashboardPage.selectCourse(course) @@ -230,7 +244,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION, false) fun testDashboardCourses_gradeIsDisplayedWhenShowGradesIsSelected() { // [Student] Grade is displayed when 'Show Grades' (located in navigation drawer) is selected - getToDashboard(courseCount = 1, favoriteCourseCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + goToDashboard(data) leftSideNavigationDrawerPage.setShowGrades(true) dashboardPage.assertShowsGrades() } @@ -239,29 +254,67 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION, false) fun testDashboardCourses_gradeIsNotDisplayedWhenShowGradesIsDeSelected() { // [Student] Grade is NOT displayed when 'Show Grades' (located in navigation drawer) is de-selected - getToDashboard(courseCount = 1, favoriteCourseCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + goToDashboard(data) leftSideNavigationDrawerPage.setShowGrades(false) dashboardPage.assertHidesGrades() } - private fun getToDashboard( - courseCount: Int = 1, - invitedCourseCount: Int = 0, - pastCourseCount: Int = 0, - favoriteCourseCount: Int = 0, - announcementCount: Int = 0 + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION, false) + fun testDashboardCourses_gradeIsDisplayedWithGradeAndScoreWhenNotRestricted() { + // [Student] Grade is displayed when 'Show Grades' (located in navigation drawer) is selected + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade("A", 100.0, data, false) + goToDashboard(data) + leftSideNavigationDrawerPage.setShowGrades(true) + dashboardPage.assertGradeText("A 100%") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION, false) + fun testDashboardCourses_gradeIsDisplayedWithGradeOnlyWhenQuantitativeDataIsRestricted() { + // [Student] Grade is displayed when 'Show Grades' (located in navigation drawer) is selected + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade("A", 100.0, data, true) + goToDashboard(data) + leftSideNavigationDrawerPage.setShowGrades(true) + dashboardPage.assertGradeText("A") + } + + private fun setUpData( + courseCount: Int = 1, + invitedCourseCount: Int = 0, + pastCourseCount: Int = 0, + favoriteCourseCount: Int = 0, + announcementCount: Int = 0 ): MockCanvas { - val data = MockCanvas.init( - studentCount = 1, - courseCount = courseCount, - invitedCourseCount = invitedCourseCount, - pastCourseCount = pastCourseCount, - favoriteCourseCount = favoriteCourseCount, - accountNotificationCount = announcementCount) + return MockCanvas.init( + studentCount = 1, + courseCount = courseCount, + invitedCourseCount = invitedCourseCount, + pastCourseCount = pastCourseCount, + favoriteCourseCount = favoriteCourseCount, + accountNotificationCount = announcementCount) + } + + private fun goToDashboard(data: MockCanvas) { val student = data.students[0] val token = data.tokenFor(student)!! tokenLogin(data.domain, token, student) dashboardPage.waitForRender() - return data + } + + private fun setUpCustomGrade(grade: String, score: Double, data: MockCanvas, restrictQuantitativeData: Boolean) { + val student = data.students[0] + val course = data.courses.values.first() + + val enrollment = course.enrollments!!.first { it.userId == student.id } + .copy(grades = Grades(currentGrade = grade, currentScore = score)) + + val newCourse = course + .copy(settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData), + enrollments = mutableListOf(enrollment)) + data.courses[course.id] = newCourse } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt index f9d0bd8da6..98af62da5a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt @@ -23,6 +23,7 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import com.google.android.play.core.appupdate.testing.FakeAppUpdateManager +import com.instructure.canvas.espresso.Stub import com.instructure.canvas.espresso.StubTablet import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.init @@ -269,7 +270,7 @@ class InAppUpdateInteractionTest : StudentTest() { } @Test - @StubTablet("Fails on Nexus 7 API level 26, phone version works correctly") + @Stub("Unstable, there is a ticket to fix this") fun flexibleUpdateCompletesIfAppRestarts() { with(appUpdateManager) { setUpdateAvailable(400) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt index 285f829948..f2e9242743 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt @@ -166,6 +166,10 @@ class DashboardPage : BasePage(R.id.dashboardPage) { onView(withId(R.id.gradeTextView)).assertDisplayed() } + fun assertGradeText(gradeText: String) { + onViewWithId(R.id.gradeTextView).assertHasText(gradeText) + } + // Assumes one course, which is favorited fun assertHidesGrades() { onView(withId(R.id.gradeTextView)).assertNotDisplayed() diff --git a/apps/student/src/main/java/com/instructure/student/holders/CourseViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/CourseViewHolder.kt index f8c4edce2e..5f383adb06 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/CourseViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/CourseViewHolder.kt @@ -89,20 +89,35 @@ class CourseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { } else { gradeTextView.setVisible() lockedGradeImage.setGone() - setGradeView(gradeTextView, courseGrade, course.textAndIconColor, root.context) + setGradeView(gradeTextView, courseGrade, course.textAndIconColor, root.context, course.settings?.restrictQuantitativeData ?: false) } } else { gradeLayout.setGone() } } - private fun setGradeView(textView: TextView, courseGrade: CourseGrade, color: Int, context: Context) { + private fun setGradeView( + textView: TextView, + courseGrade: CourseGrade, + color: Int, + context: Context, + restrictQuantitativeData: Boolean + ) { if(courseGrade.noCurrentGrade) { textView.text = context.getString(R.string.noGradeText) } else { - val scoreString = NumberHelper.doubleToPercentage(courseGrade.currentScore, 2) - textView.text = "${if(courseGrade.hasCurrentGradeString()) courseGrade.currentGrade else ""} $scoreString" - textView.contentDescription = getContentDescriptionForMinusGradeString(courseGrade.currentGrade ?: "", context) + if (restrictQuantitativeData) { + if (courseGrade.currentGrade.isNullOrEmpty()) { + textView.text = context.getString(R.string.noGradeText) + } else { + textView.text = "${courseGrade.currentGrade.orEmpty()}" + textView.contentDescription = getContentDescriptionForMinusGradeString(courseGrade.currentGrade.orEmpty(), context) + } + } else { + val scoreString = NumberHelper.doubleToPercentage(courseGrade.currentScore, 2) + textView.text = "${if(courseGrade.hasCurrentGradeString()) courseGrade.currentGrade else ""} $scoreString" + textView.contentDescription = getContentDescriptionForMinusGradeString(courseGrade.currentGrade ?: "", context) + } } textView.setTextColor(color) } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt index 8e0cacc940..853b1d7090 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt @@ -39,7 +39,7 @@ object CourseAPI { @get:GET("dashboard/dashboard_cards") val dashboardCourses: Call> - @get:GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=banner_image&include[]=sections&state[]=completed&state[]=available") + @get:GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=banner_image&include[]=sections&include[]=settings&state[]=completed&state[]=available") val firstPageCourses: Call> @GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=banner_image&include[]=sections&state[]=completed&state[]=available") @@ -57,7 +57,7 @@ object CourseAPI { @get:GET("courses?include[]=term&include[]=syllabus_body&include[]=license&include[]=is_public&include[]=permissions&enrollment_state=active") val firstPageCoursesWithSyllabusWithActiveEnrollment: Call> - @get:GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&state[]=completed&state[]=available&state[]=unpublished") + @get:GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&include[]=settings&state[]=completed&state[]=available&state[]=unpublished") val firstPageCoursesTeacher: Call> @GET("courses/{courseId}?include[]=term&include[]=permissions&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=course_image") diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Course.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Course.kt index 6aebf29621..81e6665bbd 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Course.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Course.kt @@ -78,7 +78,9 @@ data class Course( @SerializedName("course_color") val courseColor: String? = null, @SerializedName("grading_periods") - val gradingPeriods: List? = null + val gradingPeriods: List? = null, + @SerializedName("settings") + val settings: CourseSettings? = null ) : CanvasContext(), Comparable { override val type: Type get() = Type.COURSE diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CourseSettings.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CourseSettings.kt index 583a49ca89..4dc4d5cc09 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CourseSettings.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CourseSettings.kt @@ -15,9 +15,14 @@ */ package com.instructure.canvasapi2.models +import android.os.Parcelable import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +@Parcelize data class CourseSettings( @SerializedName("syllabus_course_summary") - var courseSummary: Boolean? = null -) + var courseSummary: Boolean? = null, + @SerializedName("restrict_quantitative_data") + var restrictQuantitativeData: Boolean = false, +): Parcelable diff --git a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt index bf2b1f3842..b79388d53e 100644 --- a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt +++ b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt @@ -102,7 +102,7 @@ class CoursesApiPactTests : ApiPactTestBase() { //region Test grabbing all courses // - val allCoursesQuery = "include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=banner_image&include[]=sections&state[]=completed&state[]=available" + val allCoursesQuery = "include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=banner_image&include[]=sections&include[]=settings&state[]=completed&state[]=available" val allCoursesPath = "/api/v1/courses" val allCoursesFieldInfo = listOf( // Evidently, permissions info is *not* returned from this call, even though include[]=permissions is specified From 947e20a131c5526891268afe3abee0af901f080b Mon Sep 17 00:00:00 2001 From: domonkosadam <53480952+domonkosadam@users.noreply.github.com> Date: Wed, 2 Aug 2023 14:28:14 +0200 Subject: [PATCH 17/61] Fix E2E tests --- .../java/com/instructure/student/ui/pages/DashboardPage.kt | 2 +- .../java/com/instructure/student/ui/pages/TodoPage.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt index f2e9242743..229d1253ee 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt @@ -281,7 +281,7 @@ class DashboardPage : BasePage(R.id.dashboardPage) { fun changeCourseNickname(changeTo: String) { onView(withId(R.id.newCourseNickname)).replaceText(changeTo) - onView(withText(R.string.ok) + withAncestor(R.id.buttonPanel)).click() + onView(withText(android.R.string.ok) + withAncestor(R.id.buttonPanel)).click() } fun clickCourseOverflowMenu(courseTitle: String, menuTitle: String) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/TodoPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/TodoPage.kt index dfe16866b1..656df2e5e9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/TodoPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/TodoPage.kt @@ -98,7 +98,7 @@ class TodoPage: BasePage(R.id.todoPage) { fun chooseFavoriteCourseFilter() { onView(withId(R.id.todoListFilter)).click() onView(withText(R.string.favoritedCoursesLabel) + withParent(R.id.select_dialog_listview)).click() - onView(withText(R.string.ok)).click() + onView(withText(android.R.string.ok)).click() } fun clearFilter() { From 6e8ae353c9672ca779d44888949db88d070edbc2 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Wed, 2 Aug 2023 16:12:39 +0200 Subject: [PATCH 18/61] [MBL-16864][Student][Teacher] - Add flank_e2e_min.yml min target workflow files. (#2086) refs: MBL-16864 affects: Student, Teacher release note: none --- apps/student/flank_e2e_min.yml | 26 ++++++++++++++++++++++++++ apps/teacher/flank_e2e_min.yml | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 apps/student/flank_e2e_min.yml create mode 100644 apps/teacher/flank_e2e_min.yml diff --git a/apps/student/flank_e2e_min.yml b/apps/student/flank_e2e_min.yml new file mode 100644 index 0000000000..f494132881 --- /dev/null +++ b/apps/student/flank_e2e_min.yml @@ -0,0 +1,26 @@ +gcloud: + project: delta-essence-114723 +# Use the next two lines to run locally +# app: ./build/outputs/apk/qa/debug/student-qa-debug.apk +# test: ./build/outputs/apk/androidTest/qa/debug/student-qa-debug-androidTest.apk + app: ./apps/student/build/outputs/apk/qa/debug/student-qa-debug.apk + test: ./apps/student/build/outputs/apk/androidTest/qa/debug/student-qa-debug-androidTest.apk + results-bucket: android-student + auto-google-login: true + use-orchestrator: true + performance-metrics: false + record-video: true + timeout: 60m + test-targets: + - annotation com.instructure.canvas.espresso.E2E + - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug + device: + - model: Nexus6P + version: 26 + locale: en_US + orientation: portrait + +flank: + testShards: 1 + testRuns: 1 + diff --git a/apps/teacher/flank_e2e_min.yml b/apps/teacher/flank_e2e_min.yml new file mode 100644 index 0000000000..8137c96453 --- /dev/null +++ b/apps/teacher/flank_e2e_min.yml @@ -0,0 +1,26 @@ +gcloud: + project: delta-essence-114723 + # Use the next two lines to run locally + # app: ./build/outputs/apk/qa/debug/teacher-qa-debug.apk + # test: ./build/outputs/apk/androidTest/qa/debug/teacher-qa-debug-androidTest.apk + app: ./apps/teacher/build/outputs/apk/qa/debug/teacher-qa-debug.apk + test: ./apps/teacher/build/outputs/apk/androidTest/qa/debug/teacher-qa-debug-androidTest.apk + results-bucket: android-teacher + auto-google-login: true + use-orchestrator: true + performance-metrics: false + record-video: true + timeout: 60m + test-targets: + - annotation com.instructure.canvas.espresso.E2E + - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug + device: + - model: Nexus6P + version: 26 + locale: en_US + orientation: portrait + +flank: + testShards: 1 + testRuns: 1 + From 558a4b09799e4e6508cfb0b1b04de26fd8d05d2a Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Fri, 4 Aug 2023 10:08:00 +0200 Subject: [PATCH 19/61] [MBL-16935][Parent] Parent letter grade refs: MBL-16935 affects: Parent release note: Implemented letter grade only feature * Parent letter grade * Parent letter grade minor changes * Fixed tests * minor fix * minor fix * pr comment fixes * test fixes * Added tests --- apps/flutter_parent/lib/models/alert.dart | 16 + .../flutter_parent/lib/models/assignment.dart | 4 + apps/flutter_parent/lib/models/course.dart | 4 + apps/flutter_parent/lib/models/course.g.dart | 348 +++++++++--------- .../lib/models/course_settings.dart | 4 + .../lib/models/course_settings.g.dart | 59 ++- .../lib/models/grade_cell_data.dart | 46 ++- .../lib/network/api/course_api.dart | 2 + .../lib/screens/alerts/alerts_interactor.dart | 5 +- .../assignment_details_interactor.dart | 4 +- .../assignment_details_screen.dart | 15 +- .../lib/screens/assignments/grade_cell.dart | 9 +- .../lib/screens/courses/courses_screen.dart | 9 +- .../courses/details/course_details_model.dart | 2 + .../details/course_details_screen.dart | 2 +- .../courses/details/course_grades_screen.dart | 39 +- .../lib/screens/dashboard/alert_notifier.dart | 8 +- .../lib/utils/alert_helper.dart | 36 ++ .../lib/utils/service_locator.dart | 2 + .../alerts/alerts_interactor_test.dart | 3 + .../assignments/grade_cell_data_test.dart | 90 ++++- .../courses/course_grades_screen_test.dart | 50 ++- .../screens/courses/courses_screen_test.dart | 63 ++++ .../dashboard/alert_notifier_test.dart | 16 +- .../dashboard/dashboard_screen_test.dart | 59 +-- .../test/utils/alert_helper_test.dart | 185 ++++++++++ 26 files changed, 806 insertions(+), 274 deletions(-) create mode 100644 apps/flutter_parent/lib/utils/alert_helper.dart create mode 100644 apps/flutter_parent/test/utils/alert_helper_test.dart diff --git a/apps/flutter_parent/lib/models/alert.dart b/apps/flutter_parent/lib/models/alert.dart index 78d3d4caf1..6f3d04ff2d 100644 --- a/apps/flutter_parent/lib/models/alert.dart +++ b/apps/flutter_parent/lib/models/alert.dart @@ -109,6 +109,22 @@ abstract class Alert implements Built { int index2 = htmlUrl.lastIndexOf('/discussion_topics'); return htmlUrl.substring(index1, index2); } + + String getCourseIdForGradeAlerts() { + if (alertType == AlertType.courseGradeLow || alertType == AlertType.courseGradeHigh) { + return contextId; + } else if (alertType == AlertType.assignmentGradeLow || alertType == AlertType.assignmentGradeHigh) { + return _getCourseIdFromUrl(); + } else { + return null; + } + } + + String _getCourseIdFromUrl() { + RegExp regex = RegExp(r'/courses/(\d+)/'); + Match match = regex.firstMatch(htmlUrl); + return (match != null && match.groupCount >= 1) ? match.group(1) : null; + } } /// If you need to change the values sent over the wire when serializing you diff --git a/apps/flutter_parent/lib/models/assignment.dart b/apps/flutter_parent/lib/models/assignment.dart index ce3d4d27dd..30b7f756a7 100644 --- a/apps/flutter_parent/lib/models/assignment.dart +++ b/apps/flutter_parent/lib/models/assignment.dart @@ -185,6 +185,10 @@ abstract class Assignment implements Built { bool get isDiscussion => submissionTypes.contains(SubmissionTypes.discussionTopic); bool get isQuiz => submissionTypes.contains(SubmissionTypes.onlineQuiz); + + bool isGradingTypeQuantitative() { + return gradingType == GradingType.points || gradingType == GradingType.percent; + } } @BuiltValueEnum(wireName: 'grading_type') diff --git a/apps/flutter_parent/lib/models/course.dart b/apps/flutter_parent/lib/models/course.dart index b1bd3fa47a..4d9036b03d 100644 --- a/apps/flutter_parent/lib/models/course.dart +++ b/apps/flutter_parent/lib/models/course.dart @@ -16,6 +16,7 @@ library course; import 'package:built_collection/built_collection.dart'; import 'package:built_value/built_value.dart'; import 'package:built_value/serializer.dart'; +import 'package:flutter_parent/models/course_settings.dart'; import 'package:flutter_parent/models/section.dart'; import 'package:flutter_parent/models/term.dart'; @@ -123,6 +124,9 @@ abstract class Course implements Built { @nullable BuiltList
get sections; + @nullable + CourseSettings get settings; + static void _initializeBuilder(CourseBuilder b) => b ..id = '' ..enrollments = ListBuilder() diff --git a/apps/flutter_parent/lib/models/course.g.dart b/apps/flutter_parent/lib/models/course.g.dart index 9efff7de24..3c8a4f7d71 100644 --- a/apps/flutter_parent/lib/models/course.g.dart +++ b/apps/flutter_parent/lib/models/course.g.dart @@ -87,77 +87,74 @@ class _$CourseSerializer implements StructuredSerializer { serializers.serialize(object.restrictEnrollmentsToCourseDates, specifiedType: const FullType(bool)), ]; - result.add('original_name'); - if (object.originalName == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.originalName, - specifiedType: const FullType(String))); - } - result.add('course_code'); - if (object.courseCode == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.courseCode, - specifiedType: const FullType(String))); - } - result.add('start_at'); - if (object.startAt == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.startAt, + Object value; + value = object.originalName; + + result + ..add('original_name') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.courseCode; + + result + ..add('course_code') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.startAt; + + result + ..add('start_at') + ..add(serializers.serialize(value, specifiedType: const FullType(DateTime))); - } - result.add('end_at'); - if (object.endAt == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.endAt, + value = object.endAt; + + result + ..add('end_at') + ..add(serializers.serialize(value, specifiedType: const FullType(DateTime))); - } - result.add('syllabus_body'); - if (object.syllabusBody == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.syllabusBody, - specifiedType: const FullType(String))); - } - result.add('image_download_url'); - if (object.imageDownloadUrl == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.imageDownloadUrl, - specifiedType: const FullType(String))); - } - result.add('workflow_state'); - if (object.workflowState == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.workflowState, - specifiedType: const FullType(String))); - } - result.add('default_view'); - if (object.homePage == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.homePage, + value = object.syllabusBody; + + result + ..add('syllabus_body') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.imageDownloadUrl; + + result + ..add('image_download_url') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.workflowState; + + result + ..add('workflow_state') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.homePage; + + result + ..add('default_view') + ..add(serializers.serialize(value, specifiedType: const FullType(HomePage))); - } - result.add('term'); - if (object.term == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.term, - specifiedType: const FullType(Term))); - } - result.add('sections'); - if (object.sections == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.sections, + value = object.term; + + result + ..add('term') + ..add(serializers.serialize(value, specifiedType: const FullType(Term))); + value = object.sections; + + result + ..add('sections') + ..add(serializers.serialize(value, specifiedType: const FullType(BuiltList, const [const FullType(Section)]))); - } + value = object.settings; + + result + ..add('settings') + ..add(serializers.serialize(value, + specifiedType: const FullType(CourseSettings))); + return result; } @@ -170,8 +167,7 @@ class _$CourseSerializer implements StructuredSerializer { while (iterator.moveNext()) { final key = iterator.current as String; iterator.moveNext(); - final dynamic value = iterator.current; - if (value == null) continue; + final Object value = iterator.current; switch (key) { case 'id': result.id = serializers.deserialize(value, @@ -265,6 +261,10 @@ class _$CourseSerializer implements StructuredSerializer { BuiltList, const [const FullType(Section)])) as BuiltList); break; + case 'settings': + result.settings.replace(serializers.deserialize(value, + specifiedType: const FullType(CourseSettings)) as CourseSettings); + break; } } @@ -342,6 +342,8 @@ class _$Course extends Course { final Term term; @override final BuiltList
sections; + @override + final CourseSettings settings; factory _$Course([void Function(CourseBuilder) updates]) => (new CourseBuilder()..update(updates)).build(); @@ -372,46 +374,28 @@ class _$Course extends Course { this.workflowState, this.homePage, this.term, - this.sections}) + this.sections, + this.settings}) : super._() { - if (id == null) { - throw new BuiltValueNullFieldError('Course', 'id'); - } - if (name == null) { - throw new BuiltValueNullFieldError('Course', 'name'); - } - if (hideFinalGrades == null) { - throw new BuiltValueNullFieldError('Course', 'hideFinalGrades'); - } - if (isPublic == null) { - throw new BuiltValueNullFieldError('Course', 'isPublic'); - } - if (enrollments == null) { - throw new BuiltValueNullFieldError('Course', 'enrollments'); - } - if (needsGradingCount == null) { - throw new BuiltValueNullFieldError('Course', 'needsGradingCount'); - } - if (applyAssignmentGroupWeights == null) { - throw new BuiltValueNullFieldError( - 'Course', 'applyAssignmentGroupWeights'); - } - if (isFavorite == null) { - throw new BuiltValueNullFieldError('Course', 'isFavorite'); - } - if (accessRestrictedByDate == null) { - throw new BuiltValueNullFieldError('Course', 'accessRestrictedByDate'); - } - if (hasWeightedGradingPeriods == null) { - throw new BuiltValueNullFieldError('Course', 'hasWeightedGradingPeriods'); - } - if (hasGradingPeriods == null) { - throw new BuiltValueNullFieldError('Course', 'hasGradingPeriods'); - } - if (restrictEnrollmentsToCourseDates == null) { - throw new BuiltValueNullFieldError( - 'Course', 'restrictEnrollmentsToCourseDates'); - } + BuiltValueNullFieldError.checkNotNull(id, 'Course', 'id'); + BuiltValueNullFieldError.checkNotNull(name, 'Course', 'name'); + BuiltValueNullFieldError.checkNotNull( + hideFinalGrades, 'Course', 'hideFinalGrades'); + BuiltValueNullFieldError.checkNotNull(isPublic, 'Course', 'isPublic'); + BuiltValueNullFieldError.checkNotNull(enrollments, 'Course', 'enrollments'); + BuiltValueNullFieldError.checkNotNull( + needsGradingCount, 'Course', 'needsGradingCount'); + BuiltValueNullFieldError.checkNotNull( + applyAssignmentGroupWeights, 'Course', 'applyAssignmentGroupWeights'); + BuiltValueNullFieldError.checkNotNull(isFavorite, 'Course', 'isFavorite'); + BuiltValueNullFieldError.checkNotNull( + accessRestrictedByDate, 'Course', 'accessRestrictedByDate'); + BuiltValueNullFieldError.checkNotNull( + hasWeightedGradingPeriods, 'Course', 'hasWeightedGradingPeriods'); + BuiltValueNullFieldError.checkNotNull( + hasGradingPeriods, 'Course', 'hasGradingPeriods'); + BuiltValueNullFieldError.checkNotNull(restrictEnrollmentsToCourseDates, + 'Course', 'restrictEnrollmentsToCourseDates'); } @override @@ -451,7 +435,8 @@ class _$Course extends Course { workflowState == other.workflowState && homePage == other.homePage && term == other.term && - sections == other.sections; + sections == other.sections && + settings == other.settings; } @override @@ -474,26 +459,26 @@ class _$Course extends Course { $jc( $jc( $jc( - $jc($jc($jc($jc($jc($jc($jc($jc(0, currentScore.hashCode), finalScore.hashCode), currentGrade.hashCode), finalGrade.hashCode), id.hashCode), name.hashCode), originalName.hashCode), - courseCode.hashCode), - startAt.hashCode), - endAt.hashCode), - syllabusBody.hashCode), - hideFinalGrades.hashCode), - isPublic.hashCode), - enrollments.hashCode), - needsGradingCount.hashCode), - applyAssignmentGroupWeights.hashCode), - isFavorite.hashCode), - accessRestrictedByDate.hashCode), - imageDownloadUrl.hashCode), - hasWeightedGradingPeriods.hashCode), - hasGradingPeriods.hashCode), - restrictEnrollmentsToCourseDates.hashCode), - workflowState.hashCode), - homePage.hashCode), - term.hashCode), - sections.hashCode)); + $jc($jc($jc($jc($jc($jc($jc($jc($jc(0, currentScore.hashCode), finalScore.hashCode), currentGrade.hashCode), finalGrade.hashCode), id.hashCode), name.hashCode), originalName.hashCode), courseCode.hashCode), + startAt.hashCode), + endAt.hashCode), + syllabusBody.hashCode), + hideFinalGrades.hashCode), + isPublic.hashCode), + enrollments.hashCode), + needsGradingCount.hashCode), + applyAssignmentGroupWeights.hashCode), + isFavorite.hashCode), + accessRestrictedByDate.hashCode), + imageDownloadUrl.hashCode), + hasWeightedGradingPeriods.hashCode), + hasGradingPeriods.hashCode), + restrictEnrollmentsToCourseDates.hashCode), + workflowState.hashCode), + homePage.hashCode), + term.hashCode), + sections.hashCode), + settings.hashCode)); } @override @@ -525,7 +510,8 @@ class _$Course extends Course { ..add('workflowState', workflowState) ..add('homePage', homePage) ..add('term', term) - ..add('sections', sections)) + ..add('sections', sections) + ..add('settings', settings)) .toString(); } } @@ -651,38 +637,45 @@ class CourseBuilder implements Builder { _$this._sections ??= new ListBuilder
(); set sections(ListBuilder
sections) => _$this._sections = sections; + CourseSettingsBuilder _settings; + CourseSettingsBuilder get settings => + _$this._settings ??= new CourseSettingsBuilder(); + set settings(CourseSettingsBuilder settings) => _$this._settings = settings; + CourseBuilder() { Course._initializeBuilder(this); } CourseBuilder get _$this { - if (_$v != null) { - _currentScore = _$v.currentScore; - _finalScore = _$v.finalScore; - _currentGrade = _$v.currentGrade; - _finalGrade = _$v.finalGrade; - _id = _$v.id; - _name = _$v.name; - _originalName = _$v.originalName; - _courseCode = _$v.courseCode; - _startAt = _$v.startAt; - _endAt = _$v.endAt; - _syllabusBody = _$v.syllabusBody; - _hideFinalGrades = _$v.hideFinalGrades; - _isPublic = _$v.isPublic; - _enrollments = _$v.enrollments?.toBuilder(); - _needsGradingCount = _$v.needsGradingCount; - _applyAssignmentGroupWeights = _$v.applyAssignmentGroupWeights; - _isFavorite = _$v.isFavorite; - _accessRestrictedByDate = _$v.accessRestrictedByDate; - _imageDownloadUrl = _$v.imageDownloadUrl; - _hasWeightedGradingPeriods = _$v.hasWeightedGradingPeriods; - _hasGradingPeriods = _$v.hasGradingPeriods; - _restrictEnrollmentsToCourseDates = _$v.restrictEnrollmentsToCourseDates; - _workflowState = _$v.workflowState; - _homePage = _$v.homePage; - _term = _$v.term?.toBuilder(); - _sections = _$v.sections?.toBuilder(); + final $v = _$v; + if ($v != null) { + _currentScore = $v.currentScore; + _finalScore = $v.finalScore; + _currentGrade = $v.currentGrade; + _finalGrade = $v.finalGrade; + _id = $v.id; + _name = $v.name; + _originalName = $v.originalName; + _courseCode = $v.courseCode; + _startAt = $v.startAt; + _endAt = $v.endAt; + _syllabusBody = $v.syllabusBody; + _hideFinalGrades = $v.hideFinalGrades; + _isPublic = $v.isPublic; + _enrollments = $v.enrollments.toBuilder(); + _needsGradingCount = $v.needsGradingCount; + _applyAssignmentGroupWeights = $v.applyAssignmentGroupWeights; + _isFavorite = $v.isFavorite; + _accessRestrictedByDate = $v.accessRestrictedByDate; + _imageDownloadUrl = $v.imageDownloadUrl; + _hasWeightedGradingPeriods = $v.hasWeightedGradingPeriods; + _hasGradingPeriods = $v.hasGradingPeriods; + _restrictEnrollmentsToCourseDates = $v.restrictEnrollmentsToCourseDates; + _workflowState = $v.workflowState; + _homePage = $v.homePage; + _term = $v.term?.toBuilder(); + _sections = $v.sections?.toBuilder(); + _settings = $v.settings?.toBuilder(); _$v = null; } return this; @@ -690,9 +683,7 @@ class CourseBuilder implements Builder { @override void replace(Course other) { - if (other == null) { - throw new ArgumentError.notNull('other'); - } + ArgumentError.checkNotNull(other, 'other'); _$v = other as _$Course; } @@ -711,29 +702,38 @@ class CourseBuilder implements Builder { finalScore: finalScore, currentGrade: currentGrade, finalGrade: finalGrade, - id: id, - name: name, + id: BuiltValueNullFieldError.checkNotNull(id, 'Course', 'id'), + name: + BuiltValueNullFieldError.checkNotNull(name, 'Course', 'name'), originalName: originalName, courseCode: courseCode, startAt: startAt, endAt: endAt, syllabusBody: syllabusBody, - hideFinalGrades: hideFinalGrades, - isPublic: isPublic, + hideFinalGrades: BuiltValueNullFieldError.checkNotNull( + hideFinalGrades, 'Course', 'hideFinalGrades'), + isPublic: BuiltValueNullFieldError.checkNotNull( + isPublic, 'Course', 'isPublic'), enrollments: enrollments.build(), - needsGradingCount: needsGradingCount, - applyAssignmentGroupWeights: applyAssignmentGroupWeights, - isFavorite: isFavorite, - accessRestrictedByDate: accessRestrictedByDate, + needsGradingCount: BuiltValueNullFieldError.checkNotNull( + needsGradingCount, 'Course', 'needsGradingCount'), + applyAssignmentGroupWeights: BuiltValueNullFieldError.checkNotNull( + applyAssignmentGroupWeights, 'Course', 'applyAssignmentGroupWeights'), + isFavorite: BuiltValueNullFieldError.checkNotNull( + isFavorite, 'Course', 'isFavorite'), + accessRestrictedByDate: BuiltValueNullFieldError.checkNotNull( + accessRestrictedByDate, 'Course', 'accessRestrictedByDate'), imageDownloadUrl: imageDownloadUrl, - hasWeightedGradingPeriods: hasWeightedGradingPeriods, - hasGradingPeriods: hasGradingPeriods, - restrictEnrollmentsToCourseDates: - restrictEnrollmentsToCourseDates, + hasWeightedGradingPeriods: BuiltValueNullFieldError.checkNotNull( + hasWeightedGradingPeriods, 'Course', 'hasWeightedGradingPeriods'), + hasGradingPeriods: + BuiltValueNullFieldError.checkNotNull(hasGradingPeriods, 'Course', 'hasGradingPeriods'), + restrictEnrollmentsToCourseDates: BuiltValueNullFieldError.checkNotNull(restrictEnrollmentsToCourseDates, 'Course', 'restrictEnrollmentsToCourseDates'), workflowState: workflowState, homePage: homePage, term: _term?.build(), - sections: _sections?.build()); + sections: _sections?.build(), + settings: _settings?.build()); } catch (_) { String _$failedField; try { @@ -744,6 +744,8 @@ class CourseBuilder implements Builder { _term?.build(); _$failedField = 'sections'; _sections?.build(); + _$failedField = 'settings'; + _settings?.build(); } catch (e) { throw new BuiltValueNestedFieldError( 'Course', _$failedField, e.toString()); @@ -755,4 +757,4 @@ class CourseBuilder implements Builder { } } -// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,deprecated_member_use_from_same_package,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/apps/flutter_parent/lib/models/course_settings.dart b/apps/flutter_parent/lib/models/course_settings.dart index 49ae9c3625..6764896326 100644 --- a/apps/flutter_parent/lib/models/course_settings.dart +++ b/apps/flutter_parent/lib/models/course_settings.dart @@ -26,6 +26,10 @@ abstract class CourseSettings implements Built serialize(Serializers serializers, CourseSettings object, {FullType specifiedType = FullType.unspecified}) { final result = []; - if (object.courseSummary != null) { + Object value; + value = object.courseSummary; + if (value != null) { result ..add('syllabus_course_summary') - ..add(serializers.serialize(object.courseSummary, - specifiedType: const FullType(bool))); + ..add( + serializers.serialize(value, specifiedType: const FullType(bool))); + } + value = object.restrictQuantitativeData; + if (value != null) { + result + ..add('restrict_quantitative_data') + ..add( + serializers.serialize(value, specifiedType: const FullType(bool))); } return result; } @@ -39,12 +48,16 @@ class _$CourseSettingsSerializer while (iterator.moveNext()) { final key = iterator.current as String; iterator.moveNext(); - final dynamic value = iterator.current; + final Object value = iterator.current; switch (key) { case 'syllabus_course_summary': result.courseSummary = serializers.deserialize(value, specifiedType: const FullType(bool)) as bool; break; + case 'restrict_quantitative_data': + result.restrictQuantitativeData = serializers.deserialize(value, + specifiedType: const FullType(bool)) as bool; + break; } } @@ -55,11 +68,14 @@ class _$CourseSettingsSerializer class _$CourseSettings extends CourseSettings { @override final bool courseSummary; + @override + final bool restrictQuantitativeData; factory _$CourseSettings([void Function(CourseSettingsBuilder) updates]) => (new CourseSettingsBuilder()..update(updates)).build(); - _$CourseSettings._({this.courseSummary}) : super._(); + _$CourseSettings._({this.courseSummary, this.restrictQuantitativeData}) + : super._(); @override CourseSettings rebuild(void Function(CourseSettingsBuilder) updates) => @@ -72,18 +88,22 @@ class _$CourseSettings extends CourseSettings { @override bool operator ==(Object other) { if (identical(other, this)) return true; - return other is CourseSettings && courseSummary == other.courseSummary; + return other is CourseSettings && + courseSummary == other.courseSummary && + restrictQuantitativeData == other.restrictQuantitativeData; } @override int get hashCode { - return $jf($jc(0, courseSummary.hashCode)); + return $jf( + $jc($jc(0, courseSummary.hashCode), restrictQuantitativeData.hashCode)); } @override String toString() { return (newBuiltValueToStringHelper('CourseSettings') - ..add('courseSummary', courseSummary)) + ..add('courseSummary', courseSummary) + ..add('restrictQuantitativeData', restrictQuantitativeData)) .toString(); } } @@ -97,11 +117,18 @@ class CourseSettingsBuilder set courseSummary(bool courseSummary) => _$this._courseSummary = courseSummary; + bool _restrictQuantitativeData; + bool get restrictQuantitativeData => _$this._restrictQuantitativeData; + set restrictQuantitativeData(bool restrictQuantitativeData) => + _$this._restrictQuantitativeData = restrictQuantitativeData; + CourseSettingsBuilder(); CourseSettingsBuilder get _$this { - if (_$v != null) { - _courseSummary = _$v.courseSummary; + final $v = _$v; + if ($v != null) { + _courseSummary = $v.courseSummary; + _restrictQuantitativeData = $v.restrictQuantitativeData; _$v = null; } return this; @@ -109,9 +136,7 @@ class CourseSettingsBuilder @override void replace(CourseSettings other) { - if (other == null) { - throw new ArgumentError.notNull('other'); - } + ArgumentError.checkNotNull(other, 'other'); _$v = other as _$CourseSettings; } @@ -122,11 +147,13 @@ class CourseSettingsBuilder @override _$CourseSettings build() { - final _$result = - _$v ?? new _$CourseSettings._(courseSummary: courseSummary); + final _$result = _$v ?? + new _$CourseSettings._( + courseSummary: courseSummary, + restrictQuantitativeData: restrictQuantitativeData); replace(_$result); return _$result; } } -// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,deprecated_member_use_from_same_package,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/apps/flutter_parent/lib/models/grade_cell_data.dart b/apps/flutter_parent/lib/models/grade_cell_data.dart index 4dde7ac3b7..5cc6740ce4 100644 --- a/apps/flutter_parent/lib/models/grade_cell_data.dart +++ b/apps/flutter_parent/lib/models/grade_cell_data.dart @@ -61,21 +61,26 @@ abstract class GradeCellData implements Built b ..state = GradeCellState.submitted ..submissionText = submission.submittedAt.l10nFormat( @@ -88,7 +93,7 @@ abstract class GradeCellData implements Built b - ..state = GradeCellState.graded - ..graphPercent = graphPercent - ..accentColor = accentColor - ..score = score - ..showPointsLabel = true - ..outOf = outOfText - ..grade = grade - ..gradeContentDescription = accessibleGradeString - ..latePenalty = latePenalty - ..finalGrade = finalGrade); + return restrictQuantitativeData + ? GradeCellData((b) => b + ..state = GradeCellState.gradedRestrictQuantitativeData + ..graphPercent = 1.0 + ..accentColor = accentColor + ..score = submission.grade + ..gradeContentDescription = accessibleGradeString) + : GradeCellData((b) => b + ..state = GradeCellState.graded + ..graphPercent = graphPercent + ..accentColor = accentColor + ..score = score + ..showPointsLabel = true + ..outOf = outOfText + ..grade = grade + ..gradeContentDescription = accessibleGradeString + ..latePenalty = latePenalty + ..finalGrade = finalGrade); } } -enum GradeCellState { empty, submitted, graded } +enum GradeCellState { empty, submitted, graded, gradedRestrictQuantitativeData } diff --git a/apps/flutter_parent/lib/network/api/course_api.dart b/apps/flutter_parent/lib/network/api/course_api.dart index ebf8bd4815..cc12d6f124 100644 --- a/apps/flutter_parent/lib/network/api/course_api.dart +++ b/apps/flutter_parent/lib/network/api/course_api.dart @@ -37,6 +37,7 @@ class CourseApi { 'course_image', 'sections', 'observed_users', + 'settings', ], 'enrollment_state': 'active', }; @@ -56,6 +57,7 @@ class CourseApi { 'current_grading_period_scores', 'course_image', 'observed_users', + 'settings', ] }; return fetch(canvasDio(forceRefresh: forceRefresh).get('courses/${courseId}', queryParameters: params)); diff --git a/apps/flutter_parent/lib/screens/alerts/alerts_interactor.dart b/apps/flutter_parent/lib/screens/alerts/alerts_interactor.dart index 0d138d5b71..be97b34e04 100644 --- a/apps/flutter_parent/lib/screens/alerts/alerts_interactor.dart +++ b/apps/flutter_parent/lib/screens/alerts/alerts_interactor.dart @@ -16,11 +16,14 @@ import 'package:flutter_parent/models/alert.dart'; import 'package:flutter_parent/models/alert_threshold.dart'; import 'package:flutter_parent/network/api/alert_api.dart'; import 'package:flutter_parent/screens/dashboard/alert_notifier.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:flutter_parent/utils/service_locator.dart'; class AlertsInteractor { Future getAlertsForStudent(String studentId, bool forceRefresh) async { - final alertsFuture = _alertsApi().getAlertsDepaginated(studentId, forceRefresh)?.then((list) => list + final alertsFuture = _alertsApi().getAlertsDepaginated(studentId, forceRefresh)?.then((List list) async { + return locator().filterAlerts(list); + })?.then((list) => list ..sort((a, b) { if (a.actionDate == null && b.actionDate == null) return 0; if (a.actionDate == null && b.actionDate != null) return -1; diff --git a/apps/flutter_parent/lib/screens/assignments/assignment_details_interactor.dart b/apps/flutter_parent/lib/screens/assignments/assignment_details_interactor.dart index a083796e09..0d81ef7334 100644 --- a/apps/flutter_parent/lib/screens/assignments/assignment_details_interactor.dart +++ b/apps/flutter_parent/lib/screens/assignments/assignment_details_interactor.dart @@ -29,7 +29,7 @@ class AssignmentDetailsInteractor { String assignmentId, String studentId, ) async { - final course = locator().getCourse(courseId); + final course = locator().getCourse(courseId, forceRefresh: forceRefresh); final assignment = locator().getAssignment(courseId, assignmentId, forceRefresh: forceRefresh); return AssignmentDetails( @@ -44,7 +44,7 @@ class AssignmentDetailsInteractor { String assignmentId, String studentId, ) async { - final course = locator().getCourse(courseId); + final course = locator().getCourse(courseId, forceRefresh: forceRefresh); final quiz = locator().getAssignment(courseId, assignmentId, forceRefresh: forceRefresh); return AssignmentDetails( diff --git a/apps/flutter_parent/lib/screens/assignments/assignment_details_screen.dart b/apps/flutter_parent/lib/screens/assignments/assignment_details_screen.dart index 9e1a35842c..766094f36e 100644 --- a/apps/flutter_parent/lib/screens/assignments/assignment_details_screen.dart +++ b/apps/flutter_parent/lib/screens/assignments/assignment_details_screen.dart @@ -132,6 +132,8 @@ class _AssignmentDetailsScreenState extends State { final textTheme = Theme.of(context).textTheme; final l10n = L10n(context); + final course = snapshot.data.course; + final restrictQuantitativeData = course?.settings?.restrictQuantitativeData ?? false; final assignment = snapshot.data.assignment; final submission = assignment.submission(_currentStudent.id); final fullyLocked = assignment.isFullyLocked; @@ -155,11 +157,12 @@ class _AssignmentDetailsScreenState extends State { titleStyle: textTheme.headline4, child: Row( children: [ - Text(l10n.assignmentTotalPoints(points), - style: textTheme.caption, - semanticsLabel: l10n.assignmentTotalPointsAccessible(points), - key: Key("assignment_details_total_points")), - if (showStatus) SizedBox(width: 16), + if (!restrictQuantitativeData) + Text(l10n.assignmentTotalPoints(points), + style: textTheme.caption, + semanticsLabel: l10n.assignmentTotalPointsAccessible(points), + key: Key("assignment_details_total_points")), + if (showStatus && !restrictQuantitativeData) SizedBox(width: 16), if (showStatus) _statusIcon(submitted, submittedColor), if (showStatus) SizedBox(width: 8), if (showStatus) @@ -182,7 +185,7 @@ class _AssignmentDetailsScreenState extends State { style: textTheme.subtitle1, key: Key("assignment_details_due_date")), ), ], - GradeCell.forSubmission(context, assignment, submission), + GradeCell.forSubmission(context, course?.settings?.restrictQuantitativeData ?? false, assignment, submission), ..._lockedRow(assignment), Divider(), ..._rowTile( diff --git a/apps/flutter_parent/lib/screens/assignments/grade_cell.dart b/apps/flutter_parent/lib/screens/assignments/grade_cell.dart index fa0e851a42..4a8e60b122 100644 --- a/apps/flutter_parent/lib/screens/assignments/grade_cell.dart +++ b/apps/flutter_parent/lib/screens/assignments/grade_cell.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_parent/l10n/app_localizations.dart'; import 'package:flutter_parent/models/assignment.dart'; +import 'package:flutter_parent/models/course.dart'; import 'package:flutter_parent/models/grade_cell_data.dart'; import 'package:flutter_parent/models/submission.dart'; import 'package:flutter_parent/utils/design/parent_theme.dart'; @@ -27,10 +28,12 @@ class GradeCell extends StatelessWidget { GradeCell.forSubmission( BuildContext context, + bool restrictQuantitativeData, Assignment assignment, Submission submission, { Key key, }) : data = GradeCellData.forSubmission( + restrictQuantitativeData, assignment, submission, Theme.of(context), @@ -75,7 +78,9 @@ class GradeCell extends StatelessWidget { } Widget _graded(BuildContext context, GradeCellData data) { + final bool _isGradedRestrictQuantitativeData = data.state == GradeCellState.gradedRestrictQuantitativeData; return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, key: Key('grade-cell-graded-container'), children: [ Stack( @@ -129,8 +134,8 @@ class GradeCell extends StatelessWidget { ), ], ), - SizedBox(width: 16), - Expanded( + if (!_isGradedRestrictQuantitativeData) SizedBox(width: 16), + if (!_isGradedRestrictQuantitativeData) Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/apps/flutter_parent/lib/screens/courses/courses_screen.dart b/apps/flutter_parent/lib/screens/courses/courses_screen.dart index 7541a9319d..47e1c3401e 100644 --- a/apps/flutter_parent/lib/screens/courses/courses_screen.dart +++ b/apps/flutter_parent/lib/screens/courses/courses_screen.dart @@ -123,9 +123,8 @@ class _CoursesScreenState extends State { var format = NumberFormat.percentPattern(); format.maximumFractionDigits = 2; - if (grade.isCourseGradeLocked( - forAllGradingPeriods: course?.enrollments?.any((enrollment) => enrollment.hasActiveGradingPeriod()) != true, - )) { + if (grade.isCourseGradeLocked(forAllGradingPeriods: course?.enrollments?.any((enrollment) => enrollment.hasActiveGradingPeriod()) != true) || + (course?.settings?.restrictQuantitativeData == true && grade.currentGrade() == null)) { return null; } // If there is no current grade, return 'No grade' @@ -133,7 +132,9 @@ class _CoursesScreenState extends State { // or a score var text = grade.noCurrentGrade() ? L10n(context).noGrade - : grade.currentGrade()?.isNotEmpty == true ? grade.currentGrade() : format.format(grade.currentScore() / 100); + : grade.currentGrade()?.isNotEmpty == true + ? grade.currentGrade() + : format.format(grade.currentScore() / 100); return Text( text, diff --git a/apps/flutter_parent/lib/screens/courses/details/course_details_model.dart b/apps/flutter_parent/lib/screens/courses/details/course_details_model.dart index 60eea98f46..33ef1e8f94 100644 --- a/apps/flutter_parent/lib/screens/courses/details/course_details_model.dart +++ b/apps/flutter_parent/lib/screens/courses/details/course_details_model.dart @@ -180,6 +180,8 @@ class CourseDetailsModel extends BaseModel { bool get showSummary => hasHomePageAsSyllabus && (courseSettings?.courseSummary == true); + bool get restrictQuantitativeData => courseSettings?.restrictQuantitativeData == true; + GradingPeriod currentGradingPeriod() => _currentGradingPeriod; /// This sets the next grading period to use when loadAssignments is called. [currentGradingPeriod] won't be updated diff --git a/apps/flutter_parent/lib/screens/courses/details/course_details_screen.dart b/apps/flutter_parent/lib/screens/courses/details/course_details_screen.dart index 005650dab6..46756cee46 100644 --- a/apps/flutter_parent/lib/screens/courses/details/course_details_screen.dart +++ b/apps/flutter_parent/lib/screens/courses/details/course_details_screen.dart @@ -145,7 +145,7 @@ class _CourseDetailsScreenState extends State with SingleTi return TabBarView( controller: _tabController, children: [ - CourseGradesScreen(), + CourseGradesScreen(model.restrictQuantitativeData), if (model.hasHomePageAsFrontPage) CourseFrontPageScreen(courseId: model.courseId), if (model.hasHomePageAsSyllabus) CourseSyllabusScreen(model.course.syllabusBody), if (model.showSummary) CourseSummaryScreen(), diff --git a/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart b/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart index bbe10a671c..57d692164f 100644 --- a/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart +++ b/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart @@ -36,6 +36,10 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; class CourseGradesScreen extends StatefulWidget { + final bool _restrictQuantitativeData; + + CourseGradesScreen(this._restrictQuantitativeData); + @override _CourseGradesScreenState createState() => _CourseGradesScreenState(); } @@ -149,7 +153,7 @@ class _CourseGradesScreenState extends State with AutomaticK ), children: [ ...(group.assignments.toList()..sort((a, b) => a.position.compareTo(b.position))) - .map((assignment) => _AssignmentRow(assignment: assignment)) + .map((assignment) => _AssignmentRow(assignment: assignment, restrictQuantitativeData: widget._restrictQuantitativeData)) ], ), ), @@ -246,6 +250,8 @@ class _CourseGradeHeader extends StatelessWidget { // Don't show the total if the grade is locked if (grade.isCourseGradeLocked(forAllGradingPeriods: model.currentGradingPeriod()?.id == null)) return null; + if ((model.courseSettings?.restrictQuantitativeData ?? false) && (grade.currentGrade() == null || grade.currentGrade().isEmpty)) return null; + final textTheme = Theme.of(context).textTheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -275,8 +281,9 @@ class _CourseGradeHeader extends StatelessWidget { class _AssignmentRow extends StatelessWidget { final Assignment assignment; + final bool restrictQuantitativeData; - const _AssignmentRow({Key key, this.assignment}) : super(key: key); + const _AssignmentRow({Key key, this.assignment, this.restrictQuantitativeData}) : super(key: key); @override Widget build(BuildContext context) { @@ -372,14 +379,14 @@ class _AssignmentRow extends StatelessWidget { final submission = assignment.submission(studentId); if (submission?.excused ?? false) { - text = localizations.gradeFormatScoreOutOfPointsPossible(localizations.excused, points); - semantics = localizations.contentDescriptionScoreOutOfPointsPossible('', points); + text = restrictQuantitativeData ? localizations.excused : localizations.gradeFormatScoreOutOfPointsPossible(localizations.excused, points); + semantics = restrictQuantitativeData ? localizations.excused : localizations.contentDescriptionScoreOutOfPointsPossible(localizations.excused, points); } else if (submission?.grade != null) { - text = localizations.gradeFormatScoreOutOfPointsPossible(submission.grade, points); - semantics = localizations.contentDescriptionScoreOutOfPointsPossible(submission.grade, points); + text = _formatGradeText(restrictQuantitativeData, submission.grade, points, localizations); + semantics = _formatGradeSemantics(restrictQuantitativeData, submission.grade, points, localizations); } else { - text = localizations.gradeFormatScoreOutOfPointsPossible(localizations.assignmentNoScore, points); - semantics = localizations.contentDescriptionScoreOutOfPointsPossible('', points); // Read as "out of x points" + text = _formatGradeText(restrictQuantitativeData, localizations.assignmentNoScore, points, localizations); + semantics = _formatGradeSemantics(restrictQuantitativeData, '', points, localizations); // Read as "out of x points" } return Text(text, @@ -388,6 +395,22 @@ class _AssignmentRow extends StatelessWidget { key: Key("assignment_${assignment.id}_grade")); } + String _formatGradeText(bool restrictQuantitativeData, String score, String pointsPossible, AppLocalizations localizations) { + if (restrictQuantitativeData) { + return !assignment.isGradingTypeQuantitative() ? score : ''; + } else { + return localizations.gradeFormatScoreOutOfPointsPossible(score, pointsPossible); + } + } + + String _formatGradeSemantics(bool restrictQuantitativeData, String score, String pointsPossible, AppLocalizations localizations) { + if (restrictQuantitativeData) { + return !assignment.isGradingTypeQuantitative() ? score : ''; + } else { + return localizations.contentDescriptionScoreOutOfPointsPossible(score, pointsPossible); + } + } + String _formatDate(BuildContext context, DateTime date) { final l10n = L10n(context); return date.l10nFormat(l10n.dueDateAtTime) ?? l10n.noDueDate; diff --git a/apps/flutter_parent/lib/screens/dashboard/alert_notifier.dart b/apps/flutter_parent/lib/screens/dashboard/alert_notifier.dart index 83be5c16dd..13effba602 100644 --- a/apps/flutter_parent/lib/screens/dashboard/alert_notifier.dart +++ b/apps/flutter_parent/lib/screens/dashboard/alert_notifier.dart @@ -13,7 +13,9 @@ // along with this program. If not, see . import 'package:flutter/material.dart'; +import 'package:flutter_parent/models/alert.dart'; import 'package:flutter_parent/network/api/alert_api.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:flutter_parent/utils/service_locator.dart'; class AlertCountNotifier extends ValueNotifier { @@ -21,8 +23,10 @@ class AlertCountNotifier extends ValueNotifier { update(String studentId) async { try { - final unreadCount = await locator().getUnreadCount(studentId); - value = unreadCount?.count?.asNum; + final unreadAlerts = await locator().getAlertsDepaginated(studentId, true)?.then((List list) async { + return await locator().filterAlerts(list.where((element) => element.workflowState == AlertWorkflowState.unread).toList()); + }); + value = unreadAlerts.length; } catch (e) { print(e); } diff --git a/apps/flutter_parent/lib/utils/alert_helper.dart b/apps/flutter_parent/lib/utils/alert_helper.dart new file mode 100644 index 0000000000..c2c378792d --- /dev/null +++ b/apps/flutter_parent/lib/utils/alert_helper.dart @@ -0,0 +1,36 @@ +// Copyright (C) 2023 - present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import 'package:flutter_parent/models/alert.dart'; +import 'package:flutter_parent/models/course.dart'; +import 'package:flutter_parent/network/api/course_api.dart'; +import 'package:flutter_parent/utils/service_locator.dart'; + +class AlertsHelper { + Future> filterAlerts(List list) async { + List filteredList = []; + for (var element in list) { + var courseId = element.getCourseIdForGradeAlerts(); + if (courseId == null) { + filteredList.add(element); + } else { + Course course = await locator().getCourse(courseId, forceRefresh: false); + if (!course.settings.restrictQuantitativeData) { + filteredList.add(element); + } + } + } + return filteredList; + } +} diff --git a/apps/flutter_parent/lib/utils/service_locator.dart b/apps/flutter_parent/lib/utils/service_locator.dart index 778263ba67..4165047df5 100644 --- a/apps/flutter_parent/lib/utils/service_locator.dart +++ b/apps/flutter_parent/lib/utils/service_locator.dart @@ -64,6 +64,7 @@ import 'package:flutter_parent/screens/remote_config/remote_config_interactor.da import 'package:flutter_parent/screens/settings/settings_interactor.dart'; import 'package:flutter_parent/screens/splash/splash_screen_interactor.dart'; import 'package:flutter_parent/screens/web_login/web_login_interactor.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:flutter_parent/utils/common_widgets/error_report/error_report_interactor.dart'; import 'package:flutter_parent/utils/common_widgets/view_attachment/view_attachment_interactor.dart'; import 'package:flutter_parent/utils/common_widgets/view_attachment/viewers/audio_video_attachment_viewer_interactor.dart'; @@ -173,4 +174,5 @@ void setupLocator() { locator.registerLazySingleton(() => QRLoginUtil()); locator.registerLazySingleton(() => QuickNav()); locator.registerLazySingleton(() => StudentAddedNotifier()); + locator.registerLazySingleton(() => AlertsHelper()); } diff --git a/apps/flutter_parent/test/screens/alerts/alerts_interactor_test.dart b/apps/flutter_parent/test/screens/alerts/alerts_interactor_test.dart index 8cc11cb525..00c6a93ff0 100644 --- a/apps/flutter_parent/test/screens/alerts/alerts_interactor_test.dart +++ b/apps/flutter_parent/test/screens/alerts/alerts_interactor_test.dart @@ -17,6 +17,7 @@ import 'package:flutter_parent/models/alert_threshold.dart'; import 'package:flutter_parent/network/api/alert_api.dart'; import 'package:flutter_parent/screens/alerts/alerts_interactor.dart'; import 'package:flutter_parent/screens/dashboard/alert_notifier.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -29,10 +30,12 @@ void main() { final api = MockAlertsApi(); final notifier = MockAlertCountNotifier(); + final alertsHelper = AlertsHelper(); setupTestLocator((_locator) { _locator.registerFactory(() => api); _locator.registerLazySingleton(() => notifier); + _locator.registerLazySingleton(() => alertsHelper); }); setUp(() { diff --git a/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart b/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart index ca58ab8070..95f11361fb 100644 --- a/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart +++ b/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart @@ -59,7 +59,7 @@ void main() { test('Returns empty for null submission', () { var expected = GradeCellData(); - var actual = GradeCellData.forSubmission(baseAssignment, null, theme, l10n); + var actual = GradeCellData.forSubmission(false, baseAssignment, null, theme, l10n); expect(actual, expected); }); @@ -69,7 +69,7 @@ void main() { ..graphPercent = 0.85 ..score = '85' ..showPointsLabel = true); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, baseAssignment, submission, theme, l10n); expect(actual, expected); }); @@ -81,7 +81,7 @@ void main() { ..grade = null ..score = 0.0); var expected = GradeCellData(); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, baseAssignment, submission, theme, l10n); expect(actual, expected); }); @@ -97,14 +97,14 @@ void main() { l10n.submissionStatusSuccessSubtitle, dateFormat: DateFormat.MMMMd(), )); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, baseAssignment, submission, theme, l10n); expect(actual, expected); }); test('Returns Empty state when not submitted and ungraded', () { var submission = Submission((b) => b..assignmentId = '1'); var expected = GradeCellData(); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, baseAssignment, submission, theme, l10n); expect(actual, expected); }); @@ -114,7 +114,7 @@ void main() { ..graphPercent = 1.0 ..showCompleteIcon = true ..grade = 'Excused'); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, baseAssignment, submission, theme, l10n); expect(actual, expected); }); @@ -127,7 +127,7 @@ void main() { ..showPointsLabel = true ..grade = '85%' ..gradeContentDescription = '85%'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -138,7 +138,7 @@ void main() { ..graphPercent = 1.0 ..showCompleteIcon = true ..grade = 'Complete'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -152,7 +152,7 @@ void main() { ..graphPercent = 1.0 ..showIncompleteIcon = true ..grade = 'Incomplete'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -167,7 +167,7 @@ void main() { l10n.submissionStatusSuccessSubtitle, dateFormat: DateFormat.MMMMd(), )); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -176,7 +176,7 @@ void main() { ..graphPercent = 0.85 ..score = '85' ..showPointsLabel = true); - var actual = GradeCellData.forSubmission(baseAssignment, baseSubmission, theme, l10n); + var actual = GradeCellData.forSubmission(false, baseAssignment, baseSubmission, theme, l10n); expect(actual, expected); }); @@ -189,7 +189,7 @@ void main() { ..showPointsLabel = true ..grade = 'B+' ..gradeContentDescription = 'B+'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -206,7 +206,7 @@ void main() { ..showPointsLabel = true ..grade = 'A-' ..gradeContentDescription = 'A. minus'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -219,7 +219,7 @@ void main() { ..showPointsLabel = true ..grade = '3.8 GPA' ..gradeContentDescription = '3.8 GPA'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -227,7 +227,7 @@ void main() { var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.notGraded); var submission = Submission((b) => b..assignmentId = '1'); var expected = GradeCellData(); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -242,7 +242,7 @@ void main() { ..showPointsLabel = true ..latePenalty = 'Late penalty (-6)' ..finalGrade = 'Final Grade: 79'); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, baseAssignment, submission, theme, l10n); expect(actual, expected); }); @@ -255,7 +255,7 @@ void main() { ..showPointsLabel = true ..grade = 'B-' ..gradeContentDescription = 'B. minus'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -272,7 +272,61 @@ void main() { ..showPointsLabel = true ..grade = 'B' ..gradeContentDescription = 'B'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(false, assignment, submission, theme, l10n); + expect(actual, expected); + }); + + test('Returns Empty state when quantitative data is restricted and grading type is points and not excused', () { + var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.points); + var submission = Submission((b) => b + ..assignmentId = '1' + ..score = 10.0 + ..grade = 'A'); + var expected = GradeCellData(); + var actual = GradeCellData.forSubmission(true, assignment, submission, theme, l10n); + expect(actual, expected); + }); + + test('Returns Empty state when quantitative data is restricted and grading type is percent and not excused', () { + var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.percent); + var submission = Submission((b) => b + ..assignmentId = '1' + ..score = 10.0 + ..grade = 'A'); + var expected = GradeCellData(); + var actual = GradeCellData.forSubmission(true, assignment, submission, theme, l10n); + expect(actual, expected); + }); + + test('Returns correct state when quantitative data is restricted and grading type is percent and excused', () { + var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.percent); + var submission = Submission((b) => b + ..assignmentId = '1' + ..score = 10.0 + ..grade = 'A' + ..excused = true); + var expected = baseGradedState.rebuild((b) => b + ..graphPercent = 1.0 + ..grade = l10n.excused + ..outOf = '' + ..showCompleteIcon = true); + var actual = GradeCellData.forSubmission(true, assignment, submission, theme, l10n); + expect(actual, expected); + }); + + test('Returns correct state when quantitative data is restricted and graded', () { + var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.letterGrade); + var submission = Submission((b) => b + ..assignmentId = '1' + ..score = 10.0 + ..grade = 'A'); + var expected = baseGradedState.rebuild((b) => b + ..state = GradeCellState.gradedRestrictQuantitativeData + ..graphPercent = 1.0 + ..score = submission.grade + ..gradeContentDescription = submission.grade + ..outOf = ''); + var actual = GradeCellData.forSubmission(true, assignment, submission, theme, l10n); expect(actual, expected); }); } diff --git a/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart b/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart index 7e03ef1cc9..9a8e14d41b 100644 --- a/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart +++ b/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart @@ -20,6 +20,7 @@ import 'package:flutter_parent/l10n/app_localizations.dart'; import 'package:flutter_parent/models/assignment.dart'; import 'package:flutter_parent/models/assignment_group.dart'; import 'package:flutter_parent/models/course.dart'; +import 'package:flutter_parent/models/course_settings.dart'; import 'package:flutter_parent/models/enrollment.dart'; import 'package:flutter_parent/models/grade.dart'; import 'package:flutter_parent/models/grading_period.dart'; @@ -389,6 +390,53 @@ void main() { expect(find.text(AppLocalizations().courseTotalGradeLabel), findsNothing); }); + testWidgetsWithAccessibilityChecks('is not shown when restricted and its a score', (tester) async { + final groups = [ + _mockAssignmentGroup(assignments: [_mockAssignment()]) + ]; + final enrollment = Enrollment((b) => b + ..enrollmentState = 'active' + ..grades = _mockGrade(currentScore: 12)); + final model = CourseDetailsModel(_student, _courseId); + model.course = _mockCourse(); + model.courseSettings = CourseSettings((b) => b..restrictQuantitativeData = true); + when(interactor.loadAssignmentGroups(_courseId, _studentId, null)).thenAnswer((_) async => groups); + when(interactor.loadGradingPeriods(_courseId)) + .thenAnswer((_) async => GradingPeriodResponse((b) => b..gradingPeriods = BuiltList.of(List()).toBuilder())); + when(interactor.loadEnrollmentsForGradingPeriod(_courseId, _studentId, null)).thenAnswer((_) async => [enrollment]); + + await tester.pumpWidget(_testableWidget(model)); + await tester.pump(); // Build the widget + await tester.pump(); // Let the future finish + + // Verify that we are not showing the course score if restricted + expect(find.text(AppLocalizations().courseTotalGradeLabel), findsNothing); + }); + + testWidgetsWithAccessibilityChecks('is shown when restricted and its a grade', (tester) async { + final grade = 'Big fat F'; + final groups = [ + _mockAssignmentGroup(assignments: [_mockAssignment()]) + ]; + final enrollment = Enrollment((b) => b + ..enrollmentState = 'active' + ..grades = _mockGrade(currentGrade: grade)); + final model = CourseDetailsModel(_student, _courseId); + model.course = _mockCourse(); + model.courseSettings = CourseSettings((b) => b..restrictQuantitativeData = true); + when(interactor.loadAssignmentGroups(_courseId, _studentId, null)).thenAnswer((_) async => groups); + when(interactor.loadGradingPeriods(_courseId)) + .thenAnswer((_) async => GradingPeriodResponse((b) => b..gradingPeriods = BuiltList.of(List()).toBuilder())); + when(interactor.loadEnrollmentsForGradingPeriod(_courseId, _studentId, null)).thenAnswer((_) async => [enrollment]); + + await tester.pumpWidget(_testableWidget(model)); + await tester.pump(); // Build the widget + await tester.pump(); // Let the future finish + + // Verify that we are showing the course grade when restricted + expect(find.text(grade), findsOneWidget); + }); + testWidgetsWithAccessibilityChecks('is shown when looking at a grading period', (tester) async { final groups = [ _mockAssignmentGroup(assignments: [_mockAssignment()]) @@ -718,7 +766,7 @@ Submission _mockSubmission({String assignmentId = '', String grade, bool isLate, Widget _testableWidget(CourseDetailsModel model, {PlatformConfig platformConfig = const PlatformConfig()}) { return TestApp( Scaffold( - body: ChangeNotifierProvider.value(value: model, child: CourseGradesScreen()), + body: ChangeNotifierProvider.value(value: model, child: CourseGradesScreen(false)), ), platformConfig: platformConfig, ); diff --git a/apps/flutter_parent/test/screens/courses/courses_screen_test.dart b/apps/flutter_parent/test/screens/courses/courses_screen_test.dart index 62c3d22d4c..2160f90269 100644 --- a/apps/flutter_parent/test/screens/courses/courses_screen_test.dart +++ b/apps/flutter_parent/test/screens/courses/courses_screen_test.dart @@ -193,6 +193,69 @@ void main() { final gradeWidget = find.text('90%'); expect(gradeWidget, findsNWidgets(courses.length)); }); + + testWidgetsWithAccessibilityChecks('hides score if there is a grade but no grade string and score is restricted', (tester) async { + var student = _mockStudent('1'); + var courses = List.generate( + 1, + (idx) => _mockCourse( + idx.toString(), + enrollments: ListBuilder( + [_mockEnrollment(idx.toString(), userId: student.id, computedCurrentScore: 90)], + ), + ).rebuild((b) => b..settings = (b.settings..restrictQuantitativeData = true)), + ); + + _setupLocator(_MockedCoursesInteractor(courses: courses)); + + await tester.pumpWidget(_testableMaterialWidget()); + await tester.pumpAndSettle(); + + final gradeWidget = find.text('90%'); + expect(gradeWidget, findsNothing); + }); + + testWidgetsWithAccessibilityChecks('shows score if there is a grade but no grade string and score is not restricted', (tester) async { + var student = _mockStudent('1'); + var courses = List.generate( + 1, + (idx) => _mockCourse( + idx.toString(), + enrollments: ListBuilder( + [_mockEnrollment(idx.toString(), userId: student.id, computedCurrentScore: 90)], + ), + ).rebuild((b) => b..settings = (b.settings..restrictQuantitativeData = false)), + ); + + _setupLocator(_MockedCoursesInteractor(courses: courses)); + + await tester.pumpWidget(_testableMaterialWidget()); + await tester.pumpAndSettle(); + + final gradeWidget = find.text('90%'); + expect(gradeWidget, findsNWidgets(courses.length)); + }); + + testWidgetsWithAccessibilityChecks('shows grade if restricted and its a letter grade', (tester) async { + var student = _mockStudent('1'); + var courses = List.generate( + 1, + (idx) => _mockCourse( + idx.toString(), + enrollments: ListBuilder( + [_mockEnrollment(idx.toString(), userId: student.id, computedCurrentGrade: 'A')], + ), + ).rebuild((b) => b..settings = (b.settings..restrictQuantitativeData = true)), + ); + + _setupLocator(_MockedCoursesInteractor(courses: courses)); + + await tester.pumpWidget(_testableMaterialWidget()); + await tester.pumpAndSettle(); + + final gradeWidget = find.text('A'); + expect(gradeWidget, findsNWidgets(courses.length)); + }); }); group('Interaction', () { diff --git a/apps/flutter_parent/test/screens/dashboard/alert_notifier_test.dart b/apps/flutter_parent/test/screens/dashboard/alert_notifier_test.dart index b82b17cf5c..8a22ba3cc8 100644 --- a/apps/flutter_parent/test/screens/dashboard/alert_notifier_test.dart +++ b/apps/flutter_parent/test/screens/dashboard/alert_notifier_test.dart @@ -12,9 +12,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . import 'package:built_value/json_object.dart'; +import 'package:flutter_parent/models/alert.dart'; import 'package:flutter_parent/models/unread_count.dart'; import 'package:flutter_parent/network/api/alert_api.dart'; import 'package:flutter_parent/screens/dashboard/alert_notifier.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -23,6 +25,7 @@ import '../../utils/test_helpers/mock_helpers.dart'; void main() { final api = MockAlertsApi(); + final alertsHelper = AlertsHelper(); setUp(() { reset(api); @@ -30,6 +33,7 @@ void main() { setupTestLocator((locator) { locator.registerLazySingleton(() => api); + locator.registerLazySingleton(() => alertsHelper); }); test('calls the API with the provided student id', () async { @@ -37,12 +41,20 @@ void main() { final count = 4; final notifier = AlertCountNotifier(); - when(api.getUnreadCount(studentId)).thenAnswer((_) async => UnreadCount((b) => b..count = JsonObject(count))); + final data = List.generate(4, (index) { + return Alert((b) => b + ..id = index.toString() + ..workflowState = AlertWorkflowState.unread + ..alertType = AlertType.unknown + ..lockedForUser = false); + }); + + when(api.getAlertsDepaginated(any, any)).thenAnswer((_) => Future.value(data.toList())); expect(notifier.value, 0); await notifier.update(studentId); expect(notifier.value, count); - verify(api.getUnreadCount(studentId)).called(1); + verify(api.getAlertsDepaginated(studentId, any)).called(1); }); test('handles null responses', () async { diff --git a/apps/flutter_parent/test/screens/dashboard/dashboard_screen_test.dart b/apps/flutter_parent/test/screens/dashboard/dashboard_screen_test.dart index 5c6c9010fe..a6ecd53f35 100644 --- a/apps/flutter_parent/test/screens/dashboard/dashboard_screen_test.dart +++ b/apps/flutter_parent/test/screens/dashboard/dashboard_screen_test.dart @@ -15,6 +15,7 @@ import 'package:built_value/json_object.dart'; import 'package:flutter/material.dart'; import 'package:flutter_parent/l10n/app_localizations.dart'; +import 'package:flutter_parent/models/alert.dart'; import 'package:flutter_parent/models/course.dart'; import 'package:flutter_parent/models/help_link.dart'; import 'package:flutter_parent/models/login.dart'; @@ -50,6 +51,7 @@ import 'package:flutter_parent/screens/masquerade/masquerade_screen_interactor.d import 'package:flutter_parent/screens/pairing/pairing_util.dart'; import 'package:flutter_parent/screens/settings/settings_interactor.dart'; import 'package:flutter_parent/screens/settings/settings_screen.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:flutter_parent/utils/common_widgets/badges.dart'; import 'package:flutter_parent/utils/common_widgets/empty_panda_widget.dart'; import 'package:flutter_parent/utils/db/calendar_filter_db.dart'; @@ -76,6 +78,7 @@ import '../courses/course_summary_screen_test.dart'; void main() { mockNetworkImageResponse(); final analyticsMock = _MockAnalytics(); + final alertsHelper = AlertsHelper(); _setupLocator({MockInteractor interactor, AlertsApi alertsApi, InboxApi inboxApi}) async { await setupTestLocator((locator) { @@ -97,6 +100,7 @@ void main() { locator.registerLazySingleton(() => SelectedStudentNotifier()); locator.registerLazySingleton(() => StudentAddedNotifier()); locator.registerLazySingleton(() => MockAccountsApi()); + locator.registerLazySingleton(() => alertsHelper); }); } @@ -926,68 +930,81 @@ void main() { await tester.pumpWidget(_testableMaterialWidget()); await tester.pumpAndSettle(); - // Open the nav drawer - dashboardState(tester).scaffoldKey.currentState.openDrawer(); await tester.pumpAndSettle(); - verify(alertsApi.getUnreadCount(any)).called(1); + verify(alertsApi.getAlertsDepaginated(any, any)).called(1); }); - testWidgetsWithAccessibilityChecks('Inbox count of zero hides badge', (tester) async { + testWidgetsWithAccessibilityChecks('Alerts count of zero hides badge', (tester) async { final alertsApi = MockAlertsApi(); var interactor = MockInteractor(); - when(alertsApi.getUnreadCount(any)).thenAnswer((_) => Future.value(UnreadCount((b) => b..count = JsonObject(0)))); + when(alertsApi.getAlertsDepaginated(any, any)).thenAnswer((_) => Future.value([])); await _setupLocator(interactor: interactor, alertsApi: alertsApi); await tester.pumpWidget(_testableMaterialWidget()); await tester.pumpAndSettle(); - // Open the nav drawer - dashboardState(tester).scaffoldKey.currentState.openDrawer(); await tester.pumpAndSettle(); // Assert there's no text in the alerts-count expect(find.descendant(of: find.byKey(Key('alerts-count')), matching: find.byType(Text)), findsNothing); }); - testWidgetsWithAccessibilityChecks('Displays Inbox count', (tester) async { + testWidgetsWithAccessibilityChecks('Displays Alert count', (tester) async { final alertsApi = MockAlertsApi(); var interactor = MockInteractor(); - when(alertsApi.getUnreadCount(any)) - .thenAnswer((_) => Future.value(UnreadCount((b) => b..count = JsonObject(88)))); + + final date = DateTime.now(); + final data = List.generate(5, (index) { + // Create a list of alerts with dates in ascending order (reversed) + return Alert((b) => b + ..id = index.toString() + ..workflowState = AlertWorkflowState.unread + ..actionDate = date.add(Duration(days: index)) + ..lockedForUser = false); + }); + + when(alertsApi.getAlertsDepaginated(any, any)).thenAnswer((_) => Future.value(data.toList())); + await _setupLocator(interactor: interactor, alertsApi: alertsApi); await tester.pumpWidget(_testableMaterialWidget()); await tester.pumpAndSettle(); - // Open the nav drawer - dashboardState(tester).scaffoldKey.currentState.openDrawer(); await tester.pumpAndSettle(); - expect(find.text('88'), findsOneWidget); + expect(find.text('5'), findsOneWidget); }); - testWidgetsWithAccessibilityChecks('Updates Inbox count', (tester) async { + testWidgetsWithAccessibilityChecks('Updates Alert count', (tester) async { final alertsApi = MockAlertsApi(); var interactor = MockInteractor(); - when(alertsApi.getUnreadCount(any)) - .thenAnswer((_) => Future.value(UnreadCount((b) => b..count = JsonObject(88)))); + + final date = DateTime.now(); + final data = List.generate(5, (index) { + // Create a list of alerts with dates in ascending order (reversed) + return Alert((b) => b + ..id = index.toString() + ..workflowState = AlertWorkflowState.unread + ..actionDate = date.add(Duration(days: index)) + ..lockedForUser = false); + }); + + when(alertsApi.getAlertsDepaginated(any, any)).thenAnswer((_) => Future.value(data.toList())); + await _setupLocator(interactor: interactor, alertsApi: alertsApi); await tester.pumpWidget(_testableMaterialWidget()); await tester.pumpAndSettle(); - // Open the nav drawer - dashboardState(tester).scaffoldKey.currentState.openDrawer(); await tester.pumpAndSettle(); - when(alertsApi.getUnreadCount(any)) - .thenAnswer((_) => Future.value(UnreadCount((b) => b..count = JsonObject(77)))); + when(alertsApi.getAlertsDepaginated(any, any)).thenAnswer((_) => Future.value(data.sublist(0, 4).toList())); interactor.getAlertCountNotifier().update('doesn\'t matter'); await tester.pumpAndSettle(); - expect(find.text('77'), findsOneWidget); + expect(find.text('4'), findsOneWidget); }); }); diff --git a/apps/flutter_parent/test/utils/alert_helper_test.dart b/apps/flutter_parent/test/utils/alert_helper_test.dart new file mode 100644 index 0000000000..d4bc9280b0 --- /dev/null +++ b/apps/flutter_parent/test/utils/alert_helper_test.dart @@ -0,0 +1,185 @@ +// Copyright (C) 2023 - present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import 'package:flutter_parent/models/alert.dart'; +import 'package:flutter_parent/models/course.dart'; +import 'package:flutter_parent/models/course_settings.dart'; +import 'package:flutter_parent/network/api/course_api.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import 'test_app.dart'; +import 'test_helpers/mock_helpers.dart'; + +void main() { + final courseApi = MockCourseApi(); + + final course = Course((b) => b..settings = CourseSettings((b) => b..restrictQuantitativeData = false).toBuilder()); + + final restrictedCourse = Course((b) => b..settings = CourseSettings((b) => b..restrictQuantitativeData = true).toBuilder()); + + setupTestLocator((_locator) { + _locator.registerFactory(() => courseApi); + }); + + setUp(() { + reset(courseApi); + }); + + test('filter course grade alerts if restrictQuantitativeData is true in course settings', () async { + List alerts = [ + Alert((b) => b + ..id = '1' + ..contextId = '1' + ..alertType = AlertType.courseGradeLow + ..lockedForUser = false), + Alert((b) => b + ..id = '2' + ..contextId = '2' + ..alertType = AlertType.courseGradeHigh + ..lockedForUser = false), + Alert((b) => b + ..id = '3' + ..contextId = '3' + ..alertType = AlertType.unknown + ..lockedForUser = false), + ]; + + final alertsHelper = AlertsHelper(); + + when(courseApi.getCourse(any)).thenAnswer((_) => Future.value(restrictedCourse)); + + expect((await alertsHelper.filterAlerts(alerts)), alerts.sublist(2, 3)); + }); + + test('keep course grade alerts if restrictQuantitativeData is false in course settings', () async { + List alerts = [ + Alert((b) => b + ..id = '1' + ..contextId = '1' + ..alertType = AlertType.courseGradeLow + ..lockedForUser = false), + Alert((b) => b + ..id = '2' + ..contextId = '2' + ..alertType = AlertType.courseGradeHigh + ..lockedForUser = false), + Alert((b) => b + ..id = '3' + ..contextId = '3' + ..alertType = AlertType.unknown + ..lockedForUser = false), + ]; + + final alertsHelper = AlertsHelper(); + + when(courseApi.getCourse(any)).thenAnswer((_) => Future.value(course)); + + expect((await alertsHelper.filterAlerts(alerts)), alerts); + }); + + test('filter assignment grade alerts if restrictQuantitativeData is true in course settings', () async { + List alerts = [ + Alert((b) => b + ..id = '1' + ..contextId = '1' + ..alertType = AlertType.assignmentGradeLow + ..htmlUrl = 'https://canvas.instructure.com/courses/1/assignments/1' + ..lockedForUser = false), + Alert((b) => b + ..id = '2' + ..contextId = '2' + ..alertType = AlertType.assignmentGradeHigh + ..htmlUrl = 'https://canvas.instructure.com/courses/2/assignments/2' + ..lockedForUser = false), + Alert((b) => b + ..id = '3' + ..contextId = '3' + ..alertType = AlertType.unknown + ..lockedForUser = false), + ]; + + final alertsHelper = AlertsHelper(); + + when(courseApi.getCourse(any)).thenAnswer((_) => Future.value(restrictedCourse)); + + expect((await alertsHelper.filterAlerts(alerts)), alerts.sublist(2, 3)); + }); + + test('keep assignment grade alerts if restrictQuantitativeData is false in course settings', () async { + List alerts = [ + Alert((b) => b + ..id = '1' + ..contextId = '1' + ..alertType = AlertType.assignmentGradeLow + ..htmlUrl = 'https://canvas.instructure.com/courses/1/assignments/1' + ..lockedForUser = false), + Alert((b) => b + ..id = '2' + ..contextId = '2' + ..alertType = AlertType.assignmentGradeHigh + ..htmlUrl = 'https://canvas.instructure.com/courses/2/assignments/2' + ..lockedForUser = false), + Alert((b) => b + ..id = '3' + ..contextId = '3' + ..alertType = AlertType.unknown + ..lockedForUser = false), + ]; + + final alertsHelper = AlertsHelper(); + + when(courseApi.getCourse(any)).thenAnswer((_) => Future.value(course)); + + expect((await alertsHelper.filterAlerts(alerts)), alerts); + }); + + test('keep non-grade alerts', () async { + List alerts = [ + Alert((b) => b + ..id = '1' + ..contextId = '1' + ..alertType = AlertType.courseGradeHigh + ..lockedForUser = false), + Alert((b) => b + ..id = '2' + ..contextId = '2' + ..alertType = AlertType.assignmentGradeHigh + ..htmlUrl = 'https://canvas.instructure.com/courses/2/assignments/2' + ..lockedForUser = false), + Alert((b) => b + ..id = '3' + ..contextId = '3' + ..alertType = AlertType.assignmentMissing + ..lockedForUser = false), + Alert((b) => b + ..id = '4' + ..contextId = '4' + ..alertType = AlertType.courseAnnouncement + ..lockedForUser = false), + Alert((b) => b + ..id = '5' + ..contextId = '5' + ..alertType = AlertType.institutionAnnouncement + ..lockedForUser = false), + ]; + + final alertsHelper = AlertsHelper(); + + when(courseApi.getCourse(any)).thenAnswer((_) => Future.value(restrictedCourse)); + + expect((await alertsHelper.filterAlerts(alerts)), alerts.sublist(2)); + }); +} From de33648c654586df7cac72b196725cc0a9699a3e Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Fri, 4 Aug 2023 12:33:17 +0200 Subject: [PATCH 20/61] [MBL-16915][Student] Quizzes screen letter grade only refs: MBL-16915 affects: Student release note: none * Quizzes screen letter grade only * Quizzes screen letter grade only * fix breaking testSubmit test by clearing cache dir. * Fixed PR comments --------- Co-authored-by: kdeakinstructure --- .../PickerSubmissionUploadInteractionTest.kt | 4 + .../ui/interaction/QuizListInteractionTest.kt | 98 +++++++++++++++++++ .../student/ui/pages/QuizListPage.kt | 48 +++++---- .../adapter/QuizListRecyclerAdapter.kt | 11 ++- .../student/holders/QuizViewHolder.kt | 10 +- .../canvas/espresso/mockCanvas/MockCanvas.kt | 33 ++++--- 6 files changed, 167 insertions(+), 37 deletions(-) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/interaction/QuizListInteractionTest.kt diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt index df94833479..3d3bffea00 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt @@ -22,6 +22,7 @@ import android.content.Intent import android.net.Uri import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.instructure.canvas.espresso.Stub import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment @@ -52,6 +53,9 @@ class PickerSubmissionUploadInteractionTest : StudentTest() { // Read this at set-up, because it may become nulled out soon thereafter activity = activityRule.activity + //Clear file upload cache dir. + File(getInstrumentation().targetContext.cacheDir, "file_upload").deleteRecursively() + // Copy our sample file from the assets area to the external cache dir copyAssetFileToExternalCache(activity, mockedFileName) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/QuizListInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/QuizListInteractionTest.kt new file mode 100644 index 0000000000..d35583483c --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/QuizListInteractionTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.student.ui.interaction + +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addQuizToCourse +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.CourseSettings +import com.instructure.canvasapi2.models.Quiz +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@HiltAndroidTest +class QuizListInteractionTest : StudentTest() { + + override fun displaysPageObjects() = Unit + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.QUIZZES, TestCategory.INTERACTION) + fun displaysNoQuizzesView() { + getToQuizListPage(0) + quizListPage.assertNoQuizDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.QUIZZES, TestCategory.INTERACTION) + fun displaysQuiz() { + val quiz = getToQuizListPage(1)[0] + quizListPage.assertQuizDisplayed(quiz) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.QUIZZES, TestCategory.INTERACTION) + fun displaysQuizzes() { + val quizzes = getToQuizListPage(5) + quizListPage.assertQuizItemCount(quizzes.size) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.QUIZZES, TestCategory.INTERACTION) + fun displaysQuizWithPointsIfNotRestrictQuantitativeData() { + val quiz = getToQuizListPage(1)[0] + quizListPage.assertPointsDisplayed("${quiz.pointsPossible} points") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.QUIZZES, TestCategory.INTERACTION) + fun displaysQuizWithoutPointsIfRestrictQuantitativeData() { + getToQuizListPage(1, true) + quizListPage.assertPointsNotDisplayed() + } + + private fun getToQuizListPage(itemCount: Int = 1, restrictQuantitativeData: Boolean = false): List { + val data = MockCanvas.init( + courseCount = 1, + favoriteCourseCount = 1, + studentCount = 1, + teacherCount = 1 + ) + + val course = data.courses.values.first() + data.courseSettings[course.id] = CourseSettings(restrictQuantitativeData = restrictQuantitativeData) + val student = data.students.first() + val quizList = mutableListOf() + data.courseQuizzes[course.id] = mutableListOf() + repeat(itemCount) { + val quiz = data.addQuizToCourse(course, pointsPossible = 10) + quizList.add(quiz) + } + val token = data.tokenFor(student)!! + + tokenLogin(data.domain, token, student) + dashboardPage.waitForRender() + dashboardPage.selectCourse(course) + courseBrowserPage.selectQuizzes() + + return quizList + } +} diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/QuizListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/QuizListPage.kt index c8425d2679..fab2457607 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/QuizListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/QuizListPage.kt @@ -17,26 +17,28 @@ package com.instructure.student.ui.pages import android.view.View -import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.swipeDown import androidx.test.espresso.assertion.ViewAssertions.doesNotExist -import androidx.test.espresso.matcher.ViewMatchers.hasSibling -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.canvasapi2.models.Quiz import com.instructure.dataseeding.model.QuizApiModel +import com.instructure.espresso.RecyclerViewItemCountAssertion import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.click -import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.* import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf class QuizListPage : BasePage(R.id.quizListPage) { + + fun assertNoQuizDisplayed() { + onView(allOf(withId(R.id.emptyView), isDisplayed())).assertDisplayed() + } + fun assertQuizDisplayed(quiz: QuizApiModel) { assertMatcherDisplayed(allOf(withId(R.id.title), withText(quiz.title))) } @@ -45,6 +47,10 @@ class QuizListPage : BasePage(R.id.quizListPage) { assertMatcherDisplayed(allOf(withId(R.id.title), withText(quiz.title))) } + fun assertQuizItemCount(count: Int) { + onView(withId(R.id.listView) + withAncestor(R.id.quizListPage)).check(RecyclerViewItemCountAssertion(count + 1)) + } + fun selectQuiz(quiz: QuizApiModel) { clickMatcher(allOf(withId(R.id.title), withText(quiz.title))) } @@ -53,6 +59,23 @@ class QuizListPage : BasePage(R.id.quizListPage) { clickMatcher(allOf(withId(R.id.title), withText(quiz.title))) } + fun assertQuizNotDisplayed(quiz: QuizApiModel) { + onView(withText(quiz.title)).check(doesNotExist()) + } + + fun refresh() { + onView(allOf(withId(R.id.swipeRefreshLayout), isDisplayed())) + .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) + } + + fun assertPointsDisplayed(points: String?) { + assertMatcherDisplayed(allOf(withId(R.id.points), withText(points))) + } + + fun assertPointsNotDisplayed() { + onView(withId(R.id.points)).assertNotDisplayed() + } + private fun clickMatcher(matcher: Matcher) { scrollRecyclerView(R.id.listView, matcher) onView(matcher).click() @@ -62,13 +85,4 @@ class QuizListPage : BasePage(R.id.quizListPage) { scrollRecyclerView(R.id.listView, matcher) onView(matcher).assertDisplayed() } - - fun assertQuizNotDisplayed(quiz: QuizApiModel) { - onView(withText(quiz.title)).check(doesNotExist()) - } - - fun refresh() { - onView(allOf(withId(R.id.swipeRefreshLayout), isDisplayed())) - .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) - } -} \ No newline at end of file +} diff --git a/apps/student/src/main/java/com/instructure/student/adapter/QuizListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/QuizListRecyclerAdapter.kt index 9147cd8bf0..02cf7b554c 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/QuizListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/QuizListRecyclerAdapter.kt @@ -20,8 +20,10 @@ package com.instructure.student.adapter import android.content.Context import android.view.View import androidx.recyclerview.widget.RecyclerView +import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.managers.QuizManager import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.CourseSettings import com.instructure.canvasapi2.models.Quiz import com.instructure.canvasapi2.utils.ApiType import com.instructure.canvasapi2.utils.filterWithQuery @@ -32,6 +34,7 @@ import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.pandarecycler.interfaces.ViewHolderHeaderClicked import com.instructure.pandarecycler.util.GroupSortedList import com.instructure.pandarecycler.util.Types +import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.textAndIconColor import com.instructure.pandautils.utils.toast import com.instructure.student.R @@ -49,6 +52,8 @@ class QuizListRecyclerAdapter( private var apiCall: WeaveJob? = null + private var settings: CourseSettings? = null + var searchQuery = "" set(value) { field = value @@ -94,7 +99,8 @@ class QuizListRecyclerAdapter( apiCall = tryWeave { val refreshing = isRefresh val newQuizzes = mutableListOf() - awaitPaginated> { + settings = CourseManager.getCourseSettingsAsync(canvasContext.id, refreshing).await().dataOrNull + awaitPaginated { exhaustive = true onRequestFirst { QuizManager.getFirstPageQuizList(canvasContext, refreshing, it) } onRequestNext { url, callback -> QuizManager.getNextPageQuizList(url, refreshing, callback) } @@ -120,7 +126,8 @@ class QuizListRecyclerAdapter( } override fun onBindChildHolder(holder: RecyclerView.ViewHolder, s: String, quiz: Quiz) { - (holder as? QuizViewHolder)?.bind(quiz, adapterToFragmentCallback, context, canvasContext.textAndIconColor) + val restrictQuantitativeData = settings?.restrictQuantitativeData.orDefault() + (holder as? QuizViewHolder)?.bind(quiz, adapterToFragmentCallback, context, canvasContext.textAndIconColor, restrictQuantitativeData) } override fun onBindHeaderHolder(holder: RecyclerView.ViewHolder, s: String, isExpanded: Boolean) { diff --git a/apps/student/src/main/java/com/instructure/student/holders/QuizViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/QuizViewHolder.kt index 579959a89a..e38dcad05e 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/QuizViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/QuizViewHolder.kt @@ -35,7 +35,13 @@ import java.util.* class QuizViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - fun bind(item: Quiz, adapterToFragmentCallback: AdapterToFragmentCallback?, context: Context, iconAndTextColor: Int) = with(ViewholderQuizBinding.bind(itemView)) { + fun bind( + item: Quiz, + adapterToFragmentCallback: AdapterToFragmentCallback?, + context: Context, + iconAndTextColor: Int, + restrictQuantitativeData: Boolean + ) = with(ViewholderQuizBinding.bind(itemView)) { root.setOnClickListener { adapterToFragmentCallback?.onRowClicked(item, adapterPosition, true) } // Title @@ -61,7 +67,7 @@ class QuizViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { // Points and Questions val possiblePoints = item.pointsPossible?.toDoubleOrNull() ?: 0.0 - points.setVisible(possiblePoints > 0).text = context.resources.getQuantityString( + points.setVisible(possiblePoints > 0 && !restrictQuantitativeData).text = context.resources.getQuantityString( R.plurals.pointCount, possiblePoints.toInt(), NumberHelper.formatDecimal(possiblePoints, 2, true) diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt index 5eef33883e..dba7f2f528 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt @@ -1564,7 +1564,8 @@ fun MockCanvas.addQuizToCourse( dueAt: String? = null, published: Boolean = true, lockAt: String? = null, - unlockAt: String? = null + unlockAt: String? = null, + pointsPossible: Int? = null ) : Quiz { val quizId = newItemId() val quizUrl = "https://mock-data.instructure.com/api/v1/courses/${course.id}/quizzes/$quizId" @@ -1589,21 +1590,21 @@ fun MockCanvas.addQuizToCourse( } val result = Quiz( - id = quizId, - title = title, - description = description, - quizType = quizType, - mobileUrl = quizUrl, - htmlUrl = quizUrl, - timeLimit = timeLimitSecs, - dueAt = dueAt, - published = published, - assignmentId = assignment?.id ?: 0, - lockAt = lockAt, - unlockAt = unlockAt, - allDates = listOf(AssignmentDueDate(id = newItemId(), dueAt = dueAt, lockAt = lockAt, unlockAt = unlockAt)) - - ) + id = quizId, + title = title, + description = description, + quizType = quizType, + mobileUrl = quizUrl, + htmlUrl = quizUrl, + timeLimit = timeLimitSecs, + dueAt = dueAt, + published = published, + assignmentId = assignment?.id ?: 0, + lockAt = lockAt, + unlockAt = unlockAt, + allDates = listOf(AssignmentDueDate(id = newItemId(), dueAt = dueAt, lockAt = lockAt, unlockAt = unlockAt)), + pointsPossible = pointsPossible?.toString() + ) var quizList = courseQuizzes[course.id] if(quizList == null) { From d9c32b88c4952979b9f67afbade400ca499143f5 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Mon, 7 Aug 2023 11:07:09 +0200 Subject: [PATCH 21/61] [MBL-16365][Student] Migrate file download to worker (#2087) Test plan: The functionality should be the same as before. Test downloading files: - File List - Inbox Attachments - Module files refs: MBL-16365 affects: Student release note: none --- apps/student/src/main/AndroidManifest.xml | 4 - .../student/fragment/FileDetailsFragment.kt | 11 +- .../student/fragment/FileListFragment.kt | 11 +- .../fragment/InboxConversationFragment.kt | 11 +- .../fragment/InternalWebviewFragment.kt | 7 +- .../util/FileDownloadJobIntentService.kt | 248 ------------------ .../canvasapi2/apis/FileDownloadAPI.kt | 73 ++++++ .../instructure/canvasapi2/di/ApiModule.kt | 6 + .../file/download/FileDownloadWorker.kt | 179 +++++++++++++ 9 files changed, 288 insertions(+), 262 deletions(-) delete mode 100644 apps/student/src/main/java/com/instructure/student/util/FileDownloadJobIntentService.kt create mode 100644 libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileDownloadAPI.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/file/download/FileDownloadWorker.kt diff --git a/apps/student/src/main/AndroidManifest.xml b/apps/student/src/main/AndroidManifest.xml index 189c333817..064bc4dabb 100644 --- a/apps/student/src/main/AndroidManifest.xml +++ b/apps/student/src/main/AndroidManifest.xml @@ -277,10 +277,6 @@ android:name="com.instructure.pandautils.services.NotoriousUploadService" android:exported="false" /> - - diff --git a/apps/student/src/main/java/com/instructure/student/fragment/FileDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/FileDetailsFragment.kt index 2f0382c295..71ec7a8fca 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/FileDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/FileDetailsFragment.kt @@ -23,6 +23,7 @@ import android.text.TextUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.work.WorkManager import com.bumptech.glide.Glide import com.bumptech.glide.request.RequestOptions import com.instructure.canvasapi2.managers.FileFolderManager @@ -41,20 +42,26 @@ import com.instructure.interactions.router.Route import com.instructure.pandautils.analytics.SCREEN_VIEW_FILE_DETAILS import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.features.file.download.FileDownloadWorker import com.instructure.pandautils.utils.* import com.instructure.student.R import com.instructure.student.databinding.FragmentFileDetailsBinding import com.instructure.student.events.ModuleUpdatedEvent import com.instructure.student.events.post -import com.instructure.student.util.FileDownloadJobIntentService import com.instructure.student.util.StringUtilities +import dagger.hilt.android.AndroidEntryPoint import okhttp3.ResponseBody import java.util.* +import javax.inject.Inject @ScreenView(SCREEN_VIEW_FILE_DETAILS) @PageView(url = "{canvasContext}/files/{fileId}") +@AndroidEntryPoint class FileDetailsFragment : ParentFragment() { + @Inject + lateinit var workManager: WorkManager + private val binding by viewBinding(FragmentFileDetailsBinding::bind) private var canvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) @@ -143,7 +150,7 @@ class FileDetailsFragment : ParentFragment() { } private fun downloadFile() { - FileDownloadJobIntentService.scheduleDownloadJob(requireContext(), file) + workManager.enqueue(FileDownloadWorker.createOneTimeWorkRequest(file?.displayName.orEmpty(), file?.url.orEmpty())) markAsRead() } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt index fa86e5fae8..d945d5913e 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt @@ -31,6 +31,7 @@ import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.DialogFragment import androidx.lifecycle.LiveData import androidx.work.WorkInfo +import androidx.work.WorkManager import com.instructure.canvasapi2.managers.FileFolderManager import com.instructure.canvasapi2.models.* import com.instructure.canvasapi2.utils.ApiPrefs @@ -47,6 +48,7 @@ import com.instructure.interactions.router.RouterParams import com.instructure.pandautils.analytics.SCREEN_VIEW_FILE_LIST import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.features.file.download.FileDownloadWorker import com.instructure.pandautils.features.file.upload.FileUploadDialogFragment import com.instructure.pandautils.features.file.upload.FileUploadDialogParent import com.instructure.pandautils.utils.* @@ -57,17 +59,22 @@ import com.instructure.student.databinding.FragmentFileListBinding import com.instructure.student.dialog.EditTextDialog import com.instructure.student.features.files.search.FileSearchFragment import com.instructure.student.router.RouteMatcher -import com.instructure.student.util.FileDownloadJobIntentService import com.instructure.student.util.StudentPrefs +import dagger.hilt.android.AndroidEntryPoint import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import java.util.* +import javax.inject.Inject @ScreenView(SCREEN_VIEW_FILE_LIST) @PageView +@AndroidEntryPoint class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent { + @Inject + lateinit var workManager: WorkManager + private val binding by viewBinding(FragmentFileListBinding::bind) private var canvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) @@ -350,7 +357,7 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent // First check if the Download Manager exists, and is enabled // Then check for permissions if (PermissionUtils.hasPermissions(requireActivity(), PermissionUtils.WRITE_EXTERNAL_STORAGE)) { - FileDownloadJobIntentService.scheduleDownloadJob(requireContext(), item) + workManager.enqueue(FileDownloadWorker.createOneTimeWorkRequest(item.displayName.orEmpty(), item.url.orEmpty())) } else { // Need permission requestPermissions(PermissionUtils.makeArray(PermissionUtils.WRITE_EXTERNAL_STORAGE), PermissionUtils.WRITE_FILE_PERMISSION_REQUEST_CODE) diff --git a/apps/student/src/main/java/com/instructure/student/fragment/InboxConversationFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/InboxConversationFragment.kt index 8ec882d009..826f7f7095 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/InboxConversationFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/InboxConversationFragment.kt @@ -24,6 +24,7 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.Toolbar import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import androidx.work.WorkManager import com.instructure.canvasapi2.managers.InboxManager import com.instructure.canvasapi2.models.* import com.instructure.canvasapi2.utils.ApiPrefs @@ -37,6 +38,7 @@ import com.instructure.interactions.router.RouterParams import com.instructure.pandautils.analytics.SCREEN_VIEW_INBOX_CONVERSATION import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.features.file.download.FileDownloadWorker import com.instructure.pandautils.utils.* import com.instructure.student.R import com.instructure.student.adapter.InboxConversationAdapter @@ -46,16 +48,21 @@ import com.instructure.student.events.ConversationUpdatedEvent import com.instructure.student.events.MessageAddedEvent import com.instructure.student.interfaces.MessageAdapterCallback import com.instructure.student.router.RouteMatcher -import com.instructure.student.util.FileDownloadJobIntentService import com.instructure.student.view.AttachmentView +import dagger.hilt.android.AndroidEntryPoint import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import javax.inject.Inject @ScreenView(SCREEN_VIEW_INBOX_CONVERSATION) @PageView(url = "conversations") +@AndroidEntryPoint class InboxConversationFragment : ParentFragment() { + @Inject + lateinit var workManager: WorkManager + private val binding by viewBinding(FragmentInboxConversationBinding::bind) private lateinit var recyclerBinding: PandaRecyclerRefreshLayoutBinding @@ -111,7 +118,7 @@ class InboxConversationFragment : ParentFragment() { AttachmentView.AttachmentAction.DOWNLOAD -> { if (PermissionUtils.hasPermissions(requireActivity(), PermissionUtils.WRITE_EXTERNAL_STORAGE)) { - FileDownloadJobIntentService.scheduleDownloadJob(requireContext(), attachment = attachment) + workManager.enqueue(FileDownloadWorker.createOneTimeWorkRequest(attachment.displayName.orEmpty(), attachment.url.orEmpty())) } else { requestPermissions(PermissionUtils.makeArray(PermissionUtils.WRITE_EXTERNAL_STORAGE), PermissionUtils.WRITE_FILE_PERMISSION_REQUEST_CODE) } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt index 1185f145ad..f7540bba2c 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt @@ -29,6 +29,7 @@ import android.view.View import android.view.ViewGroup import android.webkit.WebView import android.widget.ProgressBar +import androidx.work.WorkManager import com.instructure.canvasapi2.managers.OAuthManager import com.instructure.canvasapi2.managers.SubmissionManager import com.instructure.canvasapi2.models.AuthenticatedSession @@ -40,12 +41,12 @@ import com.instructure.canvasapi2.utils.isValid import com.instructure.canvasapi2.utils.weave.* import com.instructure.interactions.router.Route import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.features.file.download.FileDownloadWorker import com.instructure.pandautils.utils.* import com.instructure.pandautils.views.CanvasWebView import com.instructure.student.R import com.instructure.student.databinding.FragmentWebviewBinding import com.instructure.student.router.RouteMatcher -import com.instructure.student.util.FileDownloadJobIntentService import kotlinx.coroutines.Job import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -238,9 +239,7 @@ open class InternalWebviewFragment : ParentFragment() { } private fun downloadFile() { - if (downloadFilename != null && downloadUrl != null) { - FileDownloadJobIntentService.scheduleDownloadJob(requireContext(), downloadFilename!!, downloadUrl!!) - } + WorkManager.getInstance(requireContext()).enqueue(FileDownloadWorker.createOneTimeWorkRequest(downloadFilename.orEmpty(), downloadUrl.orEmpty())) } override fun onStart() { diff --git a/apps/student/src/main/java/com/instructure/student/util/FileDownloadJobIntentService.kt b/apps/student/src/main/java/com/instructure/student/util/FileDownloadJobIntentService.kt deleted file mode 100644 index 6570caec0d..0000000000 --- a/apps/student/src/main/java/com/instructure/student/util/FileDownloadJobIntentService.kt +++ /dev/null @@ -1,248 +0,0 @@ -/* - * Copyright (C) 2016 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package com.instructure.student.util - -import android.app.DownloadManager -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Environment -import android.util.Log -import androidx.core.app.JobIntentService -import androidx.core.app.NotificationCompat -import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.instructure.canvasapi2.models.Attachment -import com.instructure.canvasapi2.models.FileFolder -import com.instructure.student.R -import okhttp3.OkHttpClient -import okhttp3.Request -import okio.buffer -import okio.sink -import java.io.File - - -class FileDownloadJobIntentService : JobIntentService() { - - override fun onHandleWork(intent: Intent) { - val fileName = intent.extras?.getString(FILE_NAME) ?: "" - val fileUrl = intent.extras?.getString(FILE_URL) ?: "" - val fileSize = intent.extras?.getLong(FILE_SIZE) ?: 0L - val notificationId = intent.extras?.getInt(NOTIFICATION_ID) ?: 0 - - val downloadedFileName = createDownloadFileName(fileName) - - registerNotificationChannel(this) - - // Tell Android where to send the user if they click on the notification - val viewDownloadIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS) - val pendingIntent = PendingIntent.getActivity(this, 0, viewDownloadIntent, PendingIntent.FLAG_IMMUTABLE) - - // Setup a notification - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.downloadingFile)) - .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentText(downloadedFileName) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .setProgress(100, 0, true) - .setOngoing(true) - .setOnlyAlertOnce(true) - - // Show the notification - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(notificationId, notification.build()) - - val resultStatus = downloadFile(downloadedFileName, fileUrl) { downloaded -> - // Only update our notification if we know the file size - // If the file size is 0, we can't keep track of anything - val percentage = when { - fileSize == 0L || downloaded <= 0 -> 0F - else -> ((downloaded.toFloat() / fileSize) * 100).coerceIn(0f..100f).toFloat() - } - - notification.setProgress(100, percentage.toInt(), fileSize <= 0) - notificationManager.notify(notificationId, notification.build()) - } - - when (resultStatus) { - is DownloadFailed -> { - // We'll want to know if download streams are failing to open - FirebaseCrashlytics.getInstance().recordException(Throwable("The file stream failed to open when downloading a file")) - notification.setContentText(getString(R.string.downloadFailed)) - } - is BadFileUrl, is BadFileName -> notification.setContentText(getString(R.string.downloadFailed)) - is DownloadSuccess -> { - notification - .setContentTitle(downloadedFileName) - .setContentText(getString(R.string.downloadSuccessful)) - } - } - - notification - .setProgress(0, 0, false) - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .setOngoing(false) - - notificationManager.notify(notificationId, notification.build()) - } - - private fun createDownloadFileName(fileName: String): String { - var downloadedFile = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - fileName - ) - val fileNameWithoutExtension = downloadedFile.nameWithoutExtension - val fileExtension = downloadedFile.extension - var counter = 1 - while (downloadedFile.exists()) { - downloadedFile = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - "$fileNameWithoutExtension($counter).$fileExtension" - ) - counter++ - } - - return downloadedFile.name - } - - private fun downloadFile(fileName: String, fileUrl: String, updateCallback: (Long) -> Unit): DownloadStatus { - val debounce = 1000 // The time to delay sending up a notification update; Sending them too fast can cause the system to skip some updates and can cause janky UI - // NOTE: The WRITE_EXTERNAL_STORAGE permission should have been checked by this point; This will fail if that permission is not granted - Log.d(TAG, "downloadFile URL: $fileUrl") - val downloadedFile = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName) - - // Make sure we have a valid file url and name - if (fileUrl.isBlank()) { - return BadFileUrl() - } else if (fileName.isBlank()) { - // Set notification message error - return BadFileName() - } - - // Download the file - try { - val okHttp = OkHttpClient.Builder().build() - val request = Request.Builder().url(fileUrl).build() - val source = okHttp.newCall(request).execute().body?.source() ?: return DownloadFailed() - val sink = downloadedFile.sink().buffer() - - var startTime = System.currentTimeMillis() - var downloaded = 0L - var read: Long - updateCallback(0) - - val bufferSize = 8L * 1024 - val sinkBuffer = sink.buffer - - // Perform download. - read = source.read(sinkBuffer, bufferSize) - while (read != -1L) { - downloaded += read - sink.emit() - // Debounce the notification - if (System.currentTimeMillis() - startTime > debounce) { - // Update the notification - updateCallback(downloaded) - startTime = System.currentTimeMillis() - } - read = source.read(sinkBuffer, bufferSize) - } - - // Cleanup - sink.flush() - sink.close() - source.close() - return DownloadSuccess() - - } catch (e: Exception) { - downloadedFile.delete() - return DownloadFailed() - } - } - - companion object { - val TAG = "DownloadMedia" - // Keys for Job Intent Extras - val FILE_NAME = "filename" - val FILE_URL = "url" - val FILE_SIZE = "filesize" - val CONTENT_TYPE = "contenttype" - val NOTIFICATION_ID = "notificationid" - val USE_HTTPURLCONNECTION = "usehttpurlconnection" - - const val CHANNEL_ID = "uploadChannel" - - // Notification ID is passed into the extras of the job, make sure to use that for any notification updates inside the job - var notificationId = 1 - get() = ++field - - // Job ID must be unique to this Job class - val JOB_ID = 1987 - - private fun createJobIntent(fileName: String, fileUrl: String, fileSize: Long): Intent = Intent().apply { - putExtras(Bundle().apply { - putString(FILE_NAME, fileName) - putString(FILE_URL, fileUrl) - putLong(FILE_SIZE, fileSize) - putInt(NOTIFICATION_ID, notificationId) - }) - } - - @JvmOverloads - fun scheduleDownloadJob(context: Context, item: FileFolder? = null, attachment: Attachment? = null) { - val fileName = item?.displayName ?: attachment?.filename ?: "" - val url = item?.url ?: attachment?.url ?: "" - val fileSize = item?.size ?: attachment?.size ?: 0L - - scheduleDownloadJob(context, fileName, url, fileSize) - } - - fun scheduleDownloadJob(context: Context, fileName: String, fileUrl: String, fileSize: Long = 0) { - val intent = FileDownloadJobIntentService.createJobIntent(fileName, fileUrl, fileSize) - enqueueWork(context, FileDownloadJobIntentService::class.java, JOB_ID, intent) - } - - fun registerNotificationChannel(context: Context) { - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - // Prevents recreation of notification channel if it exists. - if (notificationManager.notificationChannels.any { it.id == CHANNEL_ID }) return - - // Create the NotificationChannel, but only on API 26+ because - // the NotificationChannel class is new and not in the support library - val name = context.getString(R.string.notificationChannelNameFileUploadsName) - val description = context.getString(R.string.notificationChannelNameFileUploadsDescription) - val importance = NotificationManager.IMPORTANCE_HIGH - val channel = NotificationChannel(CHANNEL_ID, name, importance) - channel.description = description - - // Register the channel with the system - notificationManager.createNotificationChannel(channel) - } - } -} - -sealed class DownloadStatus -class BadFileUrl : DownloadStatus() -class BadFileName : DownloadStatus() -class DownloadSuccess : DownloadStatus() -class DownloadFailed : DownloadStatus() diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileDownloadAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileDownloadAPI.kt new file mode 100644 index 0000000000..854320dd90 --- /dev/null +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileDownloadAPI.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.canvasapi2.apis + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import okhttp3.ResponseBody +import retrofit2.http.GET +import retrofit2.http.Streaming +import retrofit2.http.Url +import java.io.File + +interface FileDownloadAPI { + + @Streaming + @GET + suspend fun downloadFile(@Url url: String): ResponseBody +} + +sealed class DownloadState { + data class InProgress(val progress: Int) : DownloadState() + object Success : DownloadState() + data class Failure(val throwable: Throwable) : DownloadState() +} + +fun ResponseBody.saveFile(file: File): Flow { + val debounce = 500L + + return flow { + emit(DownloadState.InProgress(0)) + var lastUpdate = System.currentTimeMillis() + try { + byteStream().use { inputStream -> + file.outputStream().use { outputStream -> + val totalBytes = contentLength() + val buffer = ByteArray(8 * 1024) + var progressBytes = 0L + var bytes = inputStream.read(buffer) + + while (bytes >= 0) { + outputStream.write(buffer, 0, bytes) + progressBytes += bytes + bytes = inputStream.read(buffer) + + if (System.currentTimeMillis() - lastUpdate > debounce) { + emit(DownloadState.InProgress((progressBytes * 100 / totalBytes).toInt())) + lastUpdate = System.currentTimeMillis() + } + } + } + } + emit(DownloadState.Success) + } catch (e: Exception) { + emit(DownloadState.Failure(e)) + } + } +} \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt index b3e7ddec68..1937796805 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt @@ -2,6 +2,7 @@ package com.instructure.canvasapi2.di import com.instructure.canvasapi2.apis.CourseAPI import com.instructure.canvasapi2.apis.FeaturesAPI +import com.instructure.canvasapi2.apis.FileDownloadAPI import com.instructure.canvasapi2.apis.GroupAPI import com.instructure.canvasapi2.apis.HelpLinksAPI import com.instructure.canvasapi2.apis.InboxApi @@ -174,4 +175,9 @@ class ApiModule { fun provideFeaturesApi(): FeaturesAPI.FeaturesInterface { return RestBuilder().build(FeaturesAPI.FeaturesInterface::class.java, RestParams()) } + + @Provides + fun provideFileDownloadApi(): FileDownloadAPI { + return RestBuilder().build(FileDownloadAPI::class.java, RestParams()) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/file/download/FileDownloadWorker.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/file/download/FileDownloadWorker.kt new file mode 100644 index 0000000000..f8fb695dd3 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/file/download/FileDownloadWorker.kt @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.features.file.download + +import android.app.DownloadManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Environment +import androidx.core.app.NotificationCompat +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequest +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkerParameters +import com.instructure.canvasapi2.apis.DownloadState +import com.instructure.canvasapi2.apis.FileDownloadAPI +import com.instructure.canvasapi2.apis.saveFile +import com.instructure.pandautils.R +import com.instructure.pandautils.features.file.upload.worker.FileUploadWorker +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.io.File +import kotlin.random.Random + +@HiltWorker +class FileDownloadWorker @AssistedInject constructor( + @Assisted private val context: Context, + @Assisted workerParameters: WorkerParameters, + private val fileDownloadApi: FileDownloadAPI +) : CoroutineWorker(context, workerParameters) { + + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + override suspend fun doWork(): Result { + val fileName = inputData.getString(INPUT_FILE_NAME) ?: "" + val fileUrl = inputData.getString(INPUT_FILE_URL) ?: "" + val notificationId = Random.nextInt() + + registerNotificationChannel(context) + + val downloadFileName = createDownloadFileName(fileName) + + val downloadedFile = + File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), downloadFileName) + + setForeground(createForegroundInfo(notificationId, fileName, 0)) + var result = Result.retry() + + fileDownloadApi.downloadFile(fileUrl).saveFile(downloadedFile) + .collect { downloadState -> + when (downloadState) { + is DownloadState.InProgress -> { + setForeground(createForegroundInfo(notificationId, fileName, downloadState.progress)) + } + + is DownloadState.Failure -> { + result = Result.failure() + updateNotificationFailed(notificationId, fileName) + } + + is DownloadState.Success -> { + result = Result.success() + updateNotificationComplete(notificationId, fileName) + } + } + } + + return result + } + + private fun createDownloadFileName(fileName: String): String { + var downloadedFile = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + fileName + ) + val fileNameWithoutExtension = downloadedFile.nameWithoutExtension + val fileExtension = downloadedFile.extension + var counter = 1 + while (downloadedFile.exists()) { + downloadedFile = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "$fileNameWithoutExtension($counter).$fileExtension" + ) + counter++ + } + + return downloadedFile.name + } + + private fun registerNotificationChannel(context: Context) { + if (notificationManager.notificationChannels.any { it.id == CHANNEL_ID }) return + + val name = context.getString(R.string.notificationChannelNameFileUploadsName) + val description = context.getString(R.string.notificationChannelNameFileUploadsDescription) + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(CHANNEL_ID, name, importance) + channel.description = description + + notificationManager.createNotificationChannel(channel) + } + + private fun createForegroundInfo(notificationId: Int, fileName: String, progress: Int): ForegroundInfo { + val notification = NotificationCompat.Builder(applicationContext, FileUploadWorker.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_canvas_logo) + .setContentTitle(context.getString(R.string.downloadingFile)) + .setContentText(fileName) + .setOnlyAlertOnce(true) + .setProgress(100, progress, false) + .setOngoing(progress != 100) + .build() + + return ForegroundInfo(notificationId, notification) + } + + private fun updateNotificationComplete(notificationId: Int, fileName: String) { + val viewDownloadIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS) + val pendingIntent = PendingIntent.getActivity(context, 0, viewDownloadIntent, PendingIntent.FLAG_IMMUTABLE) + + val notification = NotificationCompat.Builder(applicationContext, FileUploadWorker.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_canvas_logo) + .setProgress(100, 100, false) + .setOngoing(false) + .setContentTitle(context.getString(R.string.downloadSuccessful)) + .setContentText(fileName) + .setContentIntent(pendingIntent) + .build() + notificationManager.notify(notificationId + 1, notification) + } + + private fun updateNotificationFailed(notificationId: Int, fileName: String) { + val notification = NotificationCompat.Builder(applicationContext, FileUploadWorker.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_canvas_logo) + .setProgress(100, 100, false) + .setOngoing(false) + .setContentTitle(context.getString(R.string.downloadFailed)) + .setContentText(fileName) + .build() + notificationManager.notify(notificationId + 1, notification) + } + + companion object { + const val INPUT_FILE_NAME = "fileName" + const val INPUT_FILE_URL = "fileUrl" + + const val CHANNEL_ID = "uploadChannel" + + fun createOneTimeWorkRequest(fileName: String, fileUrl: String): OneTimeWorkRequest { + val inputData = androidx.work.Data.Builder() + .putString(INPUT_FILE_NAME, fileName) + .putString(INPUT_FILE_URL, fileUrl) + .build() + + return OneTimeWorkRequest.Builder(FileDownloadWorker::class.java) + .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + } + } +} \ No newline at end of file From 569fd794bdd133e35359d61cd8a78a8ccca18929 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Mon, 7 Aug 2023 11:20:53 +0200 Subject: [PATCH 22/61] [MBL-16917][Student] Grades screen LGO (#2088) refs: MBL-16917 affects: Student release note: none * Grades screen total grade. * Grades screen assignment grades. * Integration tests. * Assignment list LGO. * Revert "Assignment list LGO." This reverts commit 234a97a0a954267c8648db049d8812b061c735e5. --- .../CourseGradesInteractionTest.kt | 289 ++++++++++++++++++ ....kt => ElementaryGradesInteractionTest.kt} | 2 +- .../student/ui/pages/CourseGradesPage.kt | 16 +- .../adapter/GradesListRecyclerAdapter.kt | 14 +- .../student/fragment/GradesListFragment.kt | 34 ++- .../student/holders/GradeViewHolder.kt | 14 +- .../instructure/student/util/BinderUtils.kt | 65 ++-- .../canvas/espresso/mockCanvas/MockCanvas.kt | 14 +- .../instructure/canvasapi2/apis/CourseAPI.kt | 4 +- .../canvasapi2/models/Assignment.kt | 6 + .../pact/canvas/apis/CoursesApiPactTests.kt | 2 +- 11 files changed, 411 insertions(+), 49 deletions(-) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/interaction/CourseGradesInteractionTest.kt rename apps/student/src/androidTest/java/com/instructure/student/ui/interaction/{GradesInteractionTest.kt => ElementaryGradesInteractionTest.kt} (99%) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/CourseGradesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/CourseGradesInteractionTest.kt new file mode 100644 index 0000000000..8471280aa4 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/CourseGradesInteractionTest.kt @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.student.ui.interaction + +import androidx.test.espresso.matcher.ViewMatchers +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addSubmissionForAssignment +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CourseSettings +import com.instructure.canvasapi2.models.Grades +import com.instructure.canvasapi2.models.Tab +import com.instructure.espresso.page.getStringFromResource +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData +import com.instructure.student.R +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@HiltAndroidTest +class CourseGradesInteractionTest : StudentTest() { + + override fun displaysPageObjects() = Unit + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testTotalGradeIsDisplayedWithGradeAndScoreWhenNotRestricted() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade("A", 100.0, data, false) + goToGrades(data) + courseGradesPage.assertTotalGrade(ViewMatchers.withText("100% (A)")) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testTotalGradeIsDisplayedWithOnlyScoreWhenNotRestrictedAndThereIsNoGrade() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + goToGrades(data) + courseGradesPage.assertTotalGrade(ViewMatchers.withText("100%")) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testGradeIsDisplayedWithOnlyGradeWhenQuantitativeDataIsRestricted() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade("A", 100.0, data, true) + goToGrades(data) + courseGradesPage.assertTotalGrade(ViewMatchers.withText("A")) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testNAIsDisplayedWithOnlyScoreWhenRestrictedAndThereIsNoGrade() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + goToGrades(data) + courseGradesPage.assertTotalGrade(ViewMatchers.withText(courseGradesPage.getStringFromResource(R.string.noGradeText))) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "90/100 (B)") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "90/100 (3.7)") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.POINTS, "90", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "90/100") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "EX/100") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.PERCENT, "90%", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "90%") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "Complete") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "B") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "3.7") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.POINTS, "90", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayedWithoutGrade(assignment.name!!) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "Excused") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.PERCENT, "90%", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayedWithoutGrade(assignment.name!!) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "Complete") + } + + private fun setUpData( + courseCount: Int = 1, + invitedCourseCount: Int = 0, + pastCourseCount: Int = 0, + favoriteCourseCount: Int = 0, + announcementCount: Int = 0 + ): MockCanvas { + val data = MockCanvas.init( + studentCount = 1, + courseCount = courseCount, + invitedCourseCount = invitedCourseCount, + pastCourseCount = pastCourseCount, + favoriteCourseCount = favoriteCourseCount, + accountNotificationCount = announcementCount) + + val course = data.courses.values.first() + + val gradesTab = Tab(position = 2, label = "Grades", visibility = "public", tabId = Tab.GRADES_ID) + data.courseTabs[course.id]!! += gradesTab + + return data + } + + private fun addAssignment(data: MockCanvas, gradingType: Assignment.GradingType, grade: String?, score: Double?, maxScore: Int, excused: Boolean = false): Assignment { + val course = data.courses.values.first() + val student = data.students.first() + + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", + pointsPossible = maxScore, + ) + + data.addSubmissionForAssignment(assignment.id, student.id, Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString, grade = grade, score = score, excused = excused) + + return assignment + } + + private fun goToGrades(data: MockCanvas) { + val student = data.students[0] + val token = data.tokenFor(student)!! + val course = data.courses.values.first() + tokenLogin(data.domain, token, student) + dashboardPage.waitForRender() + dashboardPage.selectCourse(course) + courseBrowserPage.selectGrades() + } + + private fun setUpCustomGrade(grade: String? = null, score: Double? = null, data: MockCanvas, restrictQuantitativeData: Boolean) { + val student = data.students[0] + val course = data.courses.values.first() + + val enrollment = course.enrollments!!.first { it.userId == student.id } + .copy( + grades = Grades(currentGrade = grade, currentScore = score), + computedCurrentGrade = grade, + computedCurrentScore = score + ) + + val newCourse = course + .copy(settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData), + enrollments = mutableListOf(enrollment)) + data.courses[course.id] = newCourse + } +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GradesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt similarity index 99% rename from apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GradesInteractionTest.kt rename to apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt index 69294fd566..1cde0ae668 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GradesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt @@ -34,7 +34,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @HiltAndroidTest -class GradesInteractionTest : StudentTest() { +class ElementaryGradesInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt index 521c4e9162..4fcb1ef161 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt @@ -33,6 +33,7 @@ import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.espresso.WaitForViewWithId import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertHasText import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.click import com.instructure.espresso.matchers.WaitForViewMatcher.waitForView @@ -41,13 +42,14 @@ import com.instructure.espresso.page.onView import com.instructure.espresso.page.plus import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText import com.instructure.espresso.scrollTo import com.instructure.espresso.typeText import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf -import java.util.concurrent.* +import java.util.concurrent.TimeUnit class CourseGradesPage : BasePage(R.id.courseGradesPage) { private val gradeLabel by WaitForViewWithId(R.id.txtOverallGradeLabel) @@ -79,6 +81,18 @@ class CourseGradesPage : BasePage(R.id.courseGradesPage) { gradeValue.check(matches(matcher)) } + fun assertAssignmentDisplayed(name: String, gradeString: String) { + onView(withId(R.id.title) + withParent(R.id.textContainer)).assertHasText(name) + val siblingMatcher = withId(R.id.title) + withText(name) + onView(withId(R.id.points) + hasSibling(siblingMatcher)).assertHasText(gradeString) + } + + fun assertAssignmentDisplayedWithoutGrade(name: String) { + onView(withId(R.id.title) + withParent(R.id.textContainer)).assertHasText(name) + val siblingMatcher = withId(R.id.title) + withText(name) + onView(withId(R.id.points) + hasSibling(siblingMatcher)).assertNotDisplayed() + } + // Hopefully this will be sufficient. We may need to add some logic to scroll // to the top of the list first. We have to use the custom constraints because the // swipeRefreshLayout may extend below the screen, and therefore may not be 90% visible. diff --git a/apps/student/src/main/java/com/instructure/student/adapter/GradesListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/GradesListRecyclerAdapter.kt index 4822ce62fa..3f6173c2f7 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/GradesListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/GradesListRecyclerAdapter.kt @@ -90,7 +90,7 @@ open class GradesListRecyclerAdapter( interface AdapterToGradesCallback { val isEdit: Boolean - fun notifyGradeChanged(courseGrade: CourseGrade?) + fun notifyGradeChanged(courseGrade: CourseGrade?, restrictQuantitativeData: Boolean) fun setTermSpinnerState(isEnabled: Boolean) fun setIsWhatIfGrading(isWhatIfGrading: Boolean) } @@ -205,7 +205,8 @@ open class GradesListRecyclerAdapter( course.enrollments?.find { it.userId == student.id }?.let { course.enrollments = mutableListOf(it) courseGrade = course.getCourseGradeFromEnrollment(it, false) - adapterToGradesCallback?.notifyGradeChanged(courseGrade) + val restrictQuantitativeData = course.settings?.restrictQuantitativeData ?: false + adapterToGradesCallback?.notifyGradeChanged(courseGrade, restrictQuantitativeData) } } } catch (e: CancellationException) { @@ -272,7 +273,8 @@ open class GradesListRecyclerAdapter( if (enrollment.isStudent && enrollment.userId == ApiPrefs.user!!.id) { val course = canvasContext as Course? courseGrade = course!!.getCourseGradeForGradingPeriodSpecificEnrollment(enrollment = enrollment) - adapterToGradesCallback?.notifyGradeChanged(courseGrade) + val restrictQuantitativeData = course.settings?.restrictQuantitativeData ?: false + adapterToGradesCallback?.notifyGradeChanged(courseGrade, restrictQuantitativeData) // We need to update the course that the fragment is using course.addEnrollment(enrollment) } @@ -282,7 +284,8 @@ open class GradesListRecyclerAdapter( private fun updateCourseGrade() { // All grading periods and no grading periods are the same case courseGrade = (canvasContext as Course).getCourseGrade(true) - adapterToGradesCallback?.notifyGradeChanged(courseGrade) + val restrictQuantitativeData = (canvasContext as Course).settings?.restrictQuantitativeData ?: false + adapterToGradesCallback?.notifyGradeChanged(courseGrade, restrictQuantitativeData) } private fun updateWithAllAssignments(forceNetwork: Boolean) { @@ -322,7 +325,8 @@ open class GradesListRecyclerAdapter( isAllPagesLoaded = true // We want to disable what if grading if MGP weights are enabled, or assignment groups are enabled - if ((canvasContext as Course).isWeightedGradingPeriods || hasValidGroupRule) { + val course = (canvasContext as Course) + if (course.isWeightedGradingPeriods || hasValidGroupRule || course.settings?.restrictQuantitativeData == true) { adapterToGradesCallback?.setIsWhatIfGrading(false) } else { adapterToGradesCallback?.setIsWhatIfGrading(true) diff --git a/apps/student/src/main/java/com/instructure/student/fragment/GradesListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/GradesListFragment.kt index 97e68de475..c8b4771c17 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/GradesListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/GradesListFragment.kt @@ -66,6 +66,7 @@ class GradesListFragment : ParentFragment(), Bookmarkable { private var gradingPeriodsList = ArrayList() private var isWhatIfGrading = false + private var restrictQuantitativeData = false private lateinit var allTermsGradingPeriod: GradingPeriod private lateinit var recyclerAdapter: GradesListRecyclerAdapter @@ -154,7 +155,11 @@ class GradesListFragment : ParentFragment(), Bookmarkable { if (showWhatIfCheckBox.isChecked) { computeGrades(showTotalCheckBox.isChecked, -1) } else { - val gradeString = formatGrade(recyclerAdapter.courseGrade, !isChecked) + val gradeString = getGradeString( + recyclerAdapter.courseGrade, + !isChecked, + restrictQuantitativeData + ) txtOverallGrade.text = gradeString txtOverallGrade.contentDescription = getContentDescriptionForMinusGradeString(gradeString, requireContext()) } @@ -205,10 +210,11 @@ class GradesListFragment : ParentFragment(), Bookmarkable { } } - override fun notifyGradeChanged(courseGrade: CourseGrade?) { + override fun notifyGradeChanged(courseGrade: CourseGrade?, restrictQuantitativeData: Boolean) { Logger.d("Logging for Grades E2E, current total grade is: ${binding.txtOverallGrade.text}") if (!isAdded) return - val gradeString = formatGrade(courseGrade, !binding.showTotalCheckBox.isChecked) + val gradeString = getGradeString(courseGrade, !binding.showTotalCheckBox.isChecked, restrictQuantitativeData) + this@GradesListFragment.restrictQuantitativeData = restrictQuantitativeData Logger.d("Logging for Grades E2E, new total grade is: $gradeString") binding.txtOverallGrade.text = gradeString binding.txtOverallGrade.contentDescription = getContentDescriptionForMinusGradeString(gradeString, requireContext()) @@ -294,12 +300,28 @@ class GradesListFragment : ParentFragment(), Bookmarkable { } } - private fun formatGrade(courseGrade: CourseGrade?, isFinal: Boolean): String { + private fun getGradeString( + courseGrade: CourseGrade?, + isFinal: Boolean, + restrictQuantitativeData: Boolean + ): String { if (courseGrade == null) return getString(R.string.noGradeText) return if (isFinal) { - if (courseGrade.noFinalGrade) getString(R.string.noGradeText) else NumberHelper.doubleToPercentage(courseGrade.finalScore) + if (courseGrade.hasFinalGradeString()) String.format(" (%s)", courseGrade.finalGrade) else "" + formatGrade(courseGrade.noFinalGrade, courseGrade.hasFinalGradeString(), courseGrade.finalGrade, courseGrade.finalScore, restrictQuantitativeData) } else { - if (courseGrade.noCurrentGrade) getString(R.string.noGradeText) else NumberHelper.doubleToPercentage(courseGrade.currentScore) + if (courseGrade.hasCurrentGradeString()) String.format(" (%s)", courseGrade.currentGrade) else "" + formatGrade(courseGrade.noCurrentGrade, courseGrade.hasCurrentGradeString(), courseGrade.currentGrade, courseGrade.currentScore, restrictQuantitativeData) + } + } + + private fun formatGrade(noGrade: Boolean, hasGradeString: Boolean, grade: String?, score: Double?, restrictQuantitativeData: Boolean): String { + return if (noGrade) { + getString(R.string.noGradeText) + } else { + if (restrictQuantitativeData) { + if (hasGradeString) grade.orEmpty() else getString(R.string.noGradeText) + } else { + NumberHelper.doubleToPercentage(score) + if (hasGradeString) String.format(" (%s)", grade) else "" + } } } diff --git a/apps/student/src/main/java/com/instructure/student/holders/GradeViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/GradeViewHolder.kt index e8c9ed4b75..84932627d0 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/GradeViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/GradeViewHolder.kt @@ -23,8 +23,15 @@ import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.utils.DateHelper -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.ColorKeeper +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.backgroundColor +import com.instructure.pandautils.utils.setGone +import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.R import com.instructure.student.adapter.GradesListRecyclerAdapter import com.instructure.student.databinding.ViewholderGradeBinding @@ -61,12 +68,15 @@ class GradeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { points.setGone() } else { val submission = assignment.submission + val restrictQuantitativeData = (canvasContext as? Course)?.settings?.restrictQuantitativeData ?: false if (submission != null && Const.PENDING_REVIEW == submission.workflowState) { points.setGone() icon.setNestedIcon(R.drawable.ic_complete_solid, canvasContext.backgroundColor) + } else if (restrictQuantitativeData && assignment.isGradingTypeQuantitative && submission?.excused != true) { + points.setGone() } else { points.setVisible() - val (grade, contentDescription) = BinderUtils.getGrade(assignment, submission, context) + val (grade, contentDescription) = BinderUtils.getGrade(assignment, submission, context, restrictQuantitativeData) points.text = grade points.contentDescription = contentDescription } diff --git a/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt b/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt index 228592e6fc..f25376f836 100644 --- a/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt +++ b/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt @@ -36,13 +36,13 @@ object BinderUtils { @Suppress("DEPRECATION") fun getHtmlAsText(html: String?) = html?.validOrNull()?.let { StringUtilities.simplifyHTML(Html.fromHtml(it)) } - fun getGrade(assignment: Assignment, submission: Submission?, context: Context): DisplayGrade { + fun getGrade(assignment: Assignment, submission: Submission?, context: Context, restrictQuantitativeData: Boolean = false): DisplayGrade { val possiblePoints = assignment.pointsPossible val pointsPossibleText = NumberHelper.formatDecimal(possiblePoints, 2, true) // No submission if (submission == null) { - return if (possiblePoints > 0) { + return if (possiblePoints > 0 && !restrictQuantitativeData) { DisplayGrade( context.getString( R.string.gradeFormatScoreOutOfPointsPossible, @@ -58,18 +58,22 @@ object BinderUtils { // Excused if (submission.excused) { - return DisplayGrade( - context.getString( - R.string.gradeFormatScoreOutOfPointsPossible, - context.getString(R.string.excused), - pointsPossibleText - ), - context.getString( - R.string.contentDescriptionScoreOutOfPointsPossible, - context.getString(R.string.gradeExcused), - pointsPossibleText + if (restrictQuantitativeData) { + return DisplayGrade(context.getString(R.string.gradeExcused)) + } else { + return DisplayGrade( + context.getString( + R.string.gradeFormatScoreOutOfPointsPossible, + context.getString(R.string.excused), + pointsPossibleText + ), + context.getString( + R.string.contentDescriptionScoreOutOfPointsPossible, + context.getString(R.string.gradeExcused), + pointsPossibleText + ) ) - ) + } } val grade = submission.grade ?: return DisplayGrade() @@ -82,26 +86,31 @@ object BinderUtils { * more closely match web, e.g. "15 / 20 (2.0)" or "80 / 100 (B-)". */ if (gradingType == Assignment.GradingType.LETTER_GRADE || gradingType == Assignment.GradingType.GPA_SCALE) { - val scoreText = NumberHelper.formatDecimal(submission.score, 2, true) - val possiblePointsText = NumberHelper.formatDecimal(possiblePoints, 2, true) - return DisplayGrade( - context.getString( - R.string.formattedScoreWithPointsPossibleAndGrade, - scoreText, - possiblePointsText, - grade - ), - context.getString( - R.string.contentDescriptionScoreWithPointsPossibleAndGrade, - scoreText, - possiblePointsText, - gradeContentDescription + if (restrictQuantitativeData) { + return DisplayGrade(grade, gradeContentDescription) + } else { + val scoreText = NumberHelper.formatDecimal(submission.score, 2, true) + val possiblePointsText = NumberHelper.formatDecimal(possiblePoints, 2, true) + return DisplayGrade( + context.getString( + R.string.formattedScoreWithPointsPossibleAndGrade, + scoreText, + possiblePointsText, + grade + ), + context.getString( + R.string.contentDescriptionScoreWithPointsPossibleAndGrade, + scoreText, + possiblePointsText, + gradeContentDescription + ) ) - ) + } } // Numeric grade submission.grade?.toDoubleOrNull()?.let { parsedGrade -> + if (restrictQuantitativeData) return DisplayGrade() val formattedGrade = NumberHelper.formatDecimal(parsedGrade, 2, true) return DisplayGrade( context.getString( diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt index dba7f2f528..59725c6e14 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt @@ -943,7 +943,9 @@ fun MockCanvas.addSubmissionForAssignment( comment: SubmissionComment? = null, state: String = "submitted", grade: String? = null, - attempt: Long = 1 + attempt: Long = 1, + score: Double? = null, + excused: Boolean = false ) : Submission { val assignment = assignments[assignmentId]!! val assignmentDueDate = assignment.dueAt?.toDate() @@ -965,7 +967,10 @@ fun MockCanvas.addSubmissionForAssignment( attachments = if(attachment != null) arrayListOf(attachment) else arrayListOf(), submissionComments = if(comment != null) listOf(comment) else listOf(), mediaContentType = attachment?.contentType, - grade = grade + grade = grade, + score = score ?: 0.0, + postedAt = Date(), + excused = excused ) // Get the submission list for the assignment, creating it if necessary @@ -993,7 +998,10 @@ fun MockCanvas.addSubmissionForAssignment( attachments = if(attachment != null) arrayListOf(attachment) else arrayListOf(), submissionComments = if(comment != null) listOf(comment) else listOf(), mediaContentType = attachment?.contentType, - grade = grade + grade = grade, + score = score ?: 0.0, + postedAt = Date(), + excused = excused ) submissionList.add(userRootSubmission) } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt index 853b1d7090..721d76d05e 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt @@ -51,7 +51,7 @@ object CourseAPI { @get:GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&state[]=current_and_concluded") val firstPageCoursesWithConcluded: Call> - @get:GET("courses?include[]=term&include[]=syllabus_body&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&state[]=completed&state[]=available&include[]=observed_users") + @get:GET("courses?include[]=term&include[]=syllabus_body&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&state[]=completed&state[]=available&include[]=observed_users&include[]=settings") val firstPageCoursesWithSyllabus: Call> @get:GET("courses?include[]=term&include[]=syllabus_body&include[]=license&include[]=is_public&include[]=permissions&enrollment_state=active") @@ -72,7 +72,7 @@ object CourseAPI { @GET("courses/{courseId}?include[]=syllabus_body&include[]=term&include[]=license&include[]=is_public&include[]=permissions") fun getCourseWithSyllabus(@Path("courseId") courseId: Long): Call - @GET("courses/{courseId}?include[]=term&include[]=permissions&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=total_scores&include[]=current_grading_period_scores&include[]=course_image") + @GET("courses/{courseId}?include[]=term&include[]=permissions&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=total_scores&include[]=current_grading_period_scores&include[]=course_image&include[]=settings") fun getCourseWithGrade(@Path("courseId") courseId: Long): Call @GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&state[]=current_and_concluded") diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Assignment.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Assignment.kt index 236e01b8bf..7ac80447ba 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Assignment.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Assignment.kt @@ -161,6 +161,12 @@ data class Assignment( } + val isGradingTypeQuantitative: Boolean + get() { + val gradingType = getGradingTypeFromAPIString(this.gradingType ?: "") + return gradingType == GradingType.PERCENT || gradingType == GradingType.POINTS + } + enum class SubmissionType(val apiString: String) { ONLINE_QUIZ("online_quiz"), NONE("none"), diff --git a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt index b79388d53e..593eced3a1 100644 --- a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt +++ b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt @@ -210,7 +210,7 @@ class CoursesApiPactTests : ApiPactTestBase() { //region Test grabbing a single course with a grade // - val courseWithGradeQuery = "include[]=term&include[]=permissions&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=total_scores&include[]=current_grading_period_scores&include[]=course_image" + val courseWithGradeQuery = "include[]=term&include[]=permissions&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=total_scores&include[]=current_grading_period_scores&include[]=course_image&include[]=settings" val courseWithGradePath = "/api/v1/courses/3" val courseWithGradeFieldInfo = PactCourseFieldConfig.fromQueryString(courseId = 3, isFavorite = true, query = courseWithGradeQuery) val courseWithGradeResponseBody = LambdaDsl.newJsonBody { obj -> From 8143314d2e81f594f62e862b7c76715eab45e5ea Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Tue, 8 Aug 2023 15:10:51 +0200 Subject: [PATCH 23/61] [MBL-16913][Student] Discussions LGO refs: MBL-16913 affects: Student release note: none --- .../interaction/DiscussionsInteractionTest.kt | 326 +++++++++++------- .../student/ui/pages/DiscussionDetailsPage.kt | 134 ++++--- .../fragment/DiscussionDetailsFragment.kt | 11 +- 3 files changed, 279 insertions(+), 192 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt index d40f45ac7c..394cab7b96 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt @@ -70,7 +70,7 @@ class DiscussionsInteractionTest : StudentTest() { // Let's attach an html attachment after the fact val attachmentHtml = - """ + """ @@ -94,8 +94,8 @@ class DiscussionsInteractionTest : StudentTest() { discussionDetailsPage.assertDescriptionText(topicDescription) discussionDetailsPage.assertMainAttachmentDisplayed() discussionDetailsPage.previewAndCheckMainAttachment( - WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), - WebViewTextCheck(Locator.ID, "p1", "-- Socrates") + WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), + WebViewTextCheck(Locator.ID, "p1", "-- Socrates") ) } @@ -126,10 +126,10 @@ class DiscussionsInteractionTest : StudentTest() { val topicName = "Discussion with link in description" data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = topicName, - topicDescription = course2Html + course = course1, + user = user1, + topicTitle = topicName, + topicDescription = course2Html ) courseBrowserPage.selectDiscussions() discussionListPage.pullToUpdate() @@ -151,16 +151,16 @@ class DiscussionsInteractionTest : StudentTest() { val replyMessage = "I'm unread (at first)" val topicHeader = data.addDiscussionTopicToCourse( - course = course, - user = user, - topicTitle = topicName, - topicDescription = topicDescription + course = course, + user = user, + topicTitle = topicName, + topicDescription = topicDescription ) val discussionEntry = data.addReplyToDiscussion( - topicHeader = topicHeader, - user = user, - replyMessage = replyMessage + topicHeader = topicHeader, + user = user, + replyMessage = replyMessage ) // Bring up discussion page @@ -187,7 +187,7 @@ class DiscussionsInteractionTest : StudentTest() { val course = data.courses.values.first() val attachmentHtml = - """ + """ @@ -201,12 +201,12 @@ class DiscussionsInteractionTest : StudentTest() { """ val topicHeader = data.addDiscussionTopicToCourse( - course = course, - user = data.users.values.first(), - topicTitle = "Awesome topic", - topicDescription = "With an attachment!" + course = course, + user = data.users.values.first(), + topicTitle = "Awesome topic", + topicDescription = "With an attachment!" ) - val attachment = createHtmlAttachment(data,attachmentHtml) + val attachment = createHtmlAttachment(data, attachmentHtml) topicHeader.attachments = mutableListOf(attachment) courseBrowserPage.selectDiscussions() @@ -214,8 +214,8 @@ class DiscussionsInteractionTest : StudentTest() { discussionDetailsPage.assertTopicInfoShowing(topicHeader) discussionDetailsPage.assertMainAttachmentDisplayed() discussionDetailsPage.previewAndCheckMainAttachment( - WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), - WebViewTextCheck(Locator.ID, "p1", "No matter where you go") + WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), + WebViewTextCheck(Locator.ID, "p1", "No matter where you go") ) } @@ -233,15 +233,15 @@ class DiscussionsInteractionTest : StudentTest() { val replyMessage = "Like me!" val topicHeader = data.addDiscussionTopicToCourse( - course = course, - user = user, - topicTitle = topicName, - topicDescription = topicDescription + course = course, + user = user, + topicTitle = topicName, + topicDescription = topicDescription ) val discussionEntry = data.addReplyToDiscussion( - topicHeader = topicHeader, - user = user, - replyMessage = replyMessage + topicHeader = topicHeader, + user = user, + replyMessage = replyMessage ) // Bring up discussion page @@ -273,18 +273,18 @@ class DiscussionsInteractionTest : StudentTest() { val replyMessage = "A grader liked me!" val topicHeader = data.addDiscussionTopicToCourse( - course = course, - user = user, - topicTitle = topicName, - topicDescription = topicDescription, - allowRating = true, - onlyGradersCanRate = true + course = course, + user = user, + topicTitle = topicName, + topicDescription = topicDescription, + allowRating = true, + onlyGradersCanRate = true ) val discussionEntry = data.addReplyToDiscussion( - topicHeader = topicHeader, - user = user, - replyMessage = replyMessage, - ratingSum = 1 + topicHeader = topicHeader, + user = user, + replyMessage = replyMessage, + ratingSum = 1 ) // Bring up discussion page @@ -308,16 +308,16 @@ class DiscussionsInteractionTest : StudentTest() { val course1 = data.courses.values.first() val user1 = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion with unlikable posts", - topicDescription = "unlikable discussion", - allowRating = false + course = course1, + user = user1, + topicTitle = "Discussion with unlikable posts", + topicDescription = "unlikable discussion", + allowRating = false ) val discussionEntry = data.addReplyToDiscussion( - topicHeader = topicHeader, - user = user1, - replyMessage = "You can't touch this!" + topicHeader = topicHeader, + user = user1, + replyMessage = "You can't touch this!" ) courseBrowserPage.selectDiscussions() discussionListPage.pullToUpdate() @@ -335,10 +335,10 @@ class DiscussionsInteractionTest : StudentTest() { val course1 = data.courses.values.first() val user1 = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion view base", - topicDescription = "A viewed discussion" + course = course1, + user = user1, + topicTitle = "Discussion view base", + topicDescription = "A viewed discussion" ) courseBrowserPage.selectDiscussions() discussionListPage.pullToUpdate() @@ -355,15 +355,15 @@ class DiscussionsInteractionTest : StudentTest() { val course1 = data.courses.values.first() val user1 = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion with replies", - topicDescription = "Reply-o-rama" + course = course1, + user = user1, + topicTitle = "Discussion with replies", + topicDescription = "Reply-o-rama" ) val discussionEntry = data.addReplyToDiscussion( - topicHeader = topicHeader, - user = user1, - replyMessage = "Replied" + topicHeader = topicHeader, + user = user1, + replyMessage = "Replied" ) courseBrowserPage.selectDiscussions() @@ -383,11 +383,11 @@ class DiscussionsInteractionTest : StudentTest() { val user1 = data.users.values.first() data.discussionRepliesEnabled = false // Do we still need these? val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion with replies disabled", - topicDescription = "Replies disabled", - allowReplies = false + course = course1, + user = user1, + topicTitle = "Discussion with replies disabled", + topicDescription = "Replies disabled", + allowReplies = false ) courseBrowserPage.selectDiscussions() discussionListPage.pullToUpdate() @@ -403,10 +403,10 @@ class DiscussionsInteractionTest : StudentTest() { val course1 = data.courses.values.first() val user1 = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion with replies enabled", - topicDescription = "Replies enabled" + course = course1, + user = user1, + topicTitle = "Discussion with replies enabled", + topicDescription = "Replies enabled" ) courseBrowserPage.selectDiscussions() discussionListPage.pullToUpdate() @@ -432,10 +432,10 @@ class DiscussionsInteractionTest : StudentTest() { val user = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user, - topicTitle = "Hey! A Discussion!", - topicDescription = "Awesome!" + course = course1, + user = user, + topicTitle = "Hey! A Discussion!", + topicDescription = "Awesome!" ) courseBrowserPage.selectDiscussions() @@ -449,7 +449,7 @@ class DiscussionsInteractionTest : StudentTest() { // to manually attach anything via Espresso, since it would require manipulating // system UIs. val attachmentHtml = - """ + """ @@ -470,9 +470,11 @@ class DiscussionsInteractionTest : StudentTest() { Thread.sleep(3000) //allow some time to the reply to propagate discussionDetailsPage.assertReplyDisplayed(discussionEntry) discussionDetailsPage.assertReplyAttachment(discussionEntry) - discussionDetailsPage.previewAndCheckReplyAttachment(discussionEntry, - WebViewTextCheck(Locator.ID,"header1", "Famous Quote"), - WebViewTextCheck(Locator.ID, "p1", "That's one small step")) + discussionDetailsPage.previewAndCheckReplyAttachment( + discussionEntry, + WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), + WebViewTextCheck(Locator.ID, "p1", "That's one small step") + ) } // Tests that we can make a threaded reply to a reply @@ -484,10 +486,10 @@ class DiscussionsInteractionTest : StudentTest() { val user = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user, - topicTitle = "Wow! A Discussion!", - topicDescription = "Cool!" + course = course1, + user = user, + topicTitle = "Wow! A Discussion!", + topicDescription = "Cool!" ) courseBrowserPage.selectDiscussions() @@ -503,7 +505,7 @@ class DiscussionsInteractionTest : StudentTest() { // Now let's reply to the reply (i.e., threaded reply) val replyReplyText = "Threaded Reply" - discussionDetailsPage.replyToReply(replyEntry,replyReplyText) + discussionDetailsPage.replyToReply(replyEntry, replyReplyText) // And verify that our reply-to-reply is showing val replyReplyEntry = findDiscussionEntry(data, topicHeader.title!!, replyReplyText) @@ -521,10 +523,10 @@ class DiscussionsInteractionTest : StudentTest() { val user = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user, - topicTitle = "Discussion threaded reply attachment", - topicDescription = "Cool!" + course = course1, + user = user, + topicTitle = "Discussion threaded reply attachment", + topicDescription = "Cool!" ) courseBrowserPage.selectDiscussions() @@ -540,14 +542,14 @@ class DiscussionsInteractionTest : StudentTest() { // Now let's reply to the reply (i.e., threaded reply) val replyReplyText = "Threaded Reply" - discussionDetailsPage.replyToReply(replyEntry,replyReplyText) + discussionDetailsPage.replyToReply(replyEntry, replyReplyText) // And verify that our reply-to-reply is showing val replyReplyEntry = findDiscussionEntry(data, topicHeader.title!!, replyReplyText) // Lets attach an html attachment behind the scenes val attachmentHtml = - """ + """ @@ -560,16 +562,18 @@ class DiscussionsInteractionTest : StudentTest() { """ - val attachment = createHtmlAttachment(data,attachmentHtml) + val attachment = createHtmlAttachment(data, attachmentHtml) replyReplyEntry.attachments = mutableListOf(attachment) discussionDetailsPage.refresh() // To pick up updated discussion reply Thread.sleep(3000) //Need this because somehow sometimes refresh does "double-refresh" and assert is failing below. discussionDetailsPage.assertReplyDisplayed(replyReplyEntry) discussionDetailsPage.assertReplyAttachment(replyReplyEntry) - discussionDetailsPage.previewAndCheckReplyAttachment(replyReplyEntry, - WebViewTextCheck(Locator.ID,"header1", "Famous Quote"), - WebViewTextCheck(Locator.ID, "p1", "The only thing we have to fear")) + discussionDetailsPage.previewAndCheckReplyAttachment( + replyReplyEntry, + WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), + WebViewTextCheck(Locator.ID, "p1", "The only thing we have to fear") + ) } @@ -590,19 +594,63 @@ class DiscussionsInteractionTest : StudentTest() { // Add an assignment val assignment = data.addAssignment( - courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, - name = assignmentName, - pointsPossible = 12 + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + name = assignmentName, + pointsPossible = 12 + ) + + // Now create a discussion associated with the assignment + val discussion = data.addDiscussionTopicToCourse( + course = course, + user = teacher, + assignment = assignment + ) + + // Sign in + val token = data.tokenFor(student)!! + tokenLogin(data.domain, token, student) + + // Navigate to discussions + dashboardPage.selectCourse(course) + courseBrowserPage.selectDiscussions() + discussionListPage.selectTopic(discussion.title!!) + discussionDetailsPage.assertPointsPossibleDisplayed(assignment.pointsPossible.toInt().toString()) + } + + // Tests a discussion with a linked assignment, show possible points if not restricted + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.DISCUSSIONS, TestCategory.INTERACTION, false) + fun testDiscussion_showPointsIfNotRestricted() { + val data = MockCanvas.init(teacherCount = 1, studentCount = 1, courseCount = 1, favoriteCourseCount = 1) + + val course = data.courses.values.first() + val student = data.students[0] + val teacher = data.teachers[0] + val assignmentName = "Assignment up for discussion" + + // Make sure we have a discussions tab + val discussionsTab = Tab(position = 2, label = "Discussions", visibility = "public", tabId = Tab.DISCUSSIONS_ID) + data.courseTabs[course.id]!! += discussionsTab + + // Add an assignment + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + name = assignmentName, + pointsPossible = 12 ) // Now create a discussion associated with the assignment val discussion = data.addDiscussionTopicToCourse( - course = course, - user = teacher, - assignment = assignment + course = course, + user = teacher, + assignment = assignment ) + // Setup course settings + data.courseSettings[course.id] = CourseSettings(restrictQuantitativeData = false) + // Sign in val token = data.tokenFor(student)!! tokenLogin(data.domain, token, student) @@ -615,12 +663,56 @@ class DiscussionsInteractionTest : StudentTest() { } + // Tests a discussion with a linked assignment, hide possible points if restricted + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.DISCUSSIONS, TestCategory.INTERACTION, false) + fun testDiscussion_hidePointsIfRestricted() { + val data = MockCanvas.init(teacherCount = 1, studentCount = 1, courseCount = 1, favoriteCourseCount = 1) + + val course = data.courses.values.first() + val student = data.students[0] + val teacher = data.teachers[0] + val assignmentName = "Assignment up for discussion" + + // Make sure we have a discussions tab + val discussionsTab = Tab(position = 2, label = "Discussions", visibility = "public", tabId = Tab.DISCUSSIONS_ID) + data.courseTabs[course.id]!! += discussionsTab + + // Add an assignment + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + name = assignmentName, + pointsPossible = 12 + ) + + // Now create a discussion associated with the assignment + val discussion = data.addDiscussionTopicToCourse( + course = course, + user = teacher, + assignment = assignment + ) + + // Setup course settings + data.courseSettings[course.id] = CourseSettings(restrictQuantitativeData = true) + + // Sign in + val token = data.tokenFor(student)!! + tokenLogin(data.domain, token, student) + + // Navigate to discussions + dashboardPage.selectCourse(course) + courseBrowserPage.selectDiscussions() + discussionListPage.selectTopic(discussion.title!!) + discussionDetailsPage.assertPointsPossibleNotDisplayed() + } + // // Utilities // // Needed to grab the discussion entry associated with a manual discussion reply - private fun findDiscussionEntry(data: MockCanvas, topicName: String, replyMessage: String) : DiscussionEntry { + private fun findDiscussionEntry(data: MockCanvas, topicName: String, replyMessage: String): DiscussionEntry { // Gotta grab our reply message... val myCourse = data.courses.values.first() val topicHeader = data.courseDiscussionTopicHeaders[myCourse.id]?.find { it.title.equals(topicName) } @@ -628,11 +720,11 @@ class DiscussionsInteractionTest : StudentTest() { val topic = data.discussionTopics[topicHeader!!.id] assertNotNull("Can't find topic", topic) var discussionEntry = topic!!.views.find { it.message.equals(replyMessage) } - if(discussionEntry == null) { + if (discussionEntry == null) { // It might be a threaded reply topic.views.forEach { view -> view.replies?.forEach { reply -> - if(reply.message.equals(replyMessage)) { + if (reply.message.equals(replyMessage)) { return reply } } @@ -645,13 +737,15 @@ class DiscussionsInteractionTest : StudentTest() { // Mock a specified number of students and courses, and navigate to the first course private fun getToCourse( - studentCount: Int = 1, - courseCount: Int = 1, - enableDiscussionTopicCreation: Boolean = true): MockCanvas { + studentCount: Int = 1, + courseCount: Int = 1, + enableDiscussionTopicCreation: Boolean = true + ): MockCanvas { val data = MockCanvas.init( - studentCount = studentCount, - courseCount = courseCount, - favoriteCourseCount = courseCount) + studentCount = studentCount, + courseCount = courseCount, + favoriteCourseCount = courseCount + ) if (enableDiscussionTopicCreation) { data.courses.values.forEach { course -> @@ -677,19 +771,19 @@ class DiscussionsInteractionTest : StudentTest() { fun createHtmlAttachment(data: MockCanvas, html: String): RemoteFile { val course1 = data.courses.values.first() val fileId = data.addFileToCourse( - courseId = course1.id, - displayName = "page.html", - contentType = "text/html", - fileContent = html + courseId = course1.id, + displayName = "page.html", + contentType = "text/html", + fileContent = html ) val attachment = RemoteFile( - id = fileId, - displayName = "page.html", - fileName = "page.html", - contentType = "text/html", - url = "https://mock-data.instructure.com/files/$fileId/preview", - size = html.length.toLong() + id = fileId, + displayName = "page.html", + fileName = "page.html", + contentType = "text/html", + url = "https://mock-data.instructure.com/files/$fileId/preview", + size = html.length.toLong() ) return attachment diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt index a27751758d..c69c5c1484 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt @@ -26,9 +26,7 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches import androidx.test.espresso.web.sugar.Web.onWebView -import androidx.test.espresso.web.webdriver.DriverAtoms.findElement -import androidx.test.espresso.web.webdriver.DriverAtoms.getText -import androidx.test.espresso.web.webdriver.DriverAtoms.webClick +import androidx.test.espresso.web.webdriver.DriverAtoms.* import androidx.test.espresso.web.webdriver.Locator import com.instructure.canvas.espresso.* import com.instructure.canvasapi2.models.DiscussionEntry @@ -55,8 +53,8 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { fun assertDescriptionText(descriptionText: String) { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionTopicHeaderWebViewWrapper)) - .withElement(findElement(Locator.ID,"content")) - .check(webMatches(getText(), containsString(descriptionText))) + .withElement(findElement(Locator.ID, "content")) + .check(webMatches(getText(), containsString(descriptionText))) } fun assertTopicInfoShowing(topicHeader: DiscussionTopicHeader) { @@ -64,16 +62,16 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { assertDescriptionText(topicHeader.message!!) } - fun clickLinkInDescription(linkElementId : String) { - onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionTopicHeaderWebViewWrapper)) - .withElement(findElement(Locator.ID,linkElementId)) - .perform(webClick()) + fun clickLinkInDescription(linkElementId: String) { + onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionTopicHeaderWebViewWrapper)) + .withElement(findElement(Locator.ID, linkElementId)) + .perform(webClick()) } fun refresh() { scrollToTop() onView(allOf(withId(R.id.swipeRefreshLayout), isDisplayingAtLeast(10))) - .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) + .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) } fun scrollToRepliesWebview() { @@ -116,14 +114,13 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { fun assertReplyDisplayed(reply: DiscussionEntry, refreshesAllowed: Int = 0) { // Allow up to refreshesAllowed attempt/refresh cycles - for(i in 0..refreshesAllowed-1) { + for (i in 0 until refreshesAllowed) { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.ID, "message_content_${reply.id}")) - .check(webMatches(getText(),containsString(reply.message))) + .withElement(findElement(Locator.ID, "message_content_${reply.id}")) + .check(webMatches(getText(), containsString(reply.message))) return - } - catch(t: Throwable) { + } catch (t: Throwable) { refresh() } } @@ -134,8 +131,8 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { // (It can take a *long* time for the reply to get rendered to the webview on // tablets (in FTL, anyway).) onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElementRepeat(findElement(Locator.ID, "message_content_${reply.id}"), 3) - .check(webMatches(getText(),containsString(reply.message))) + .withElementRepeat(findElement(Locator.ID, "message_content_${reply.id}"), 3) + .check(webMatches(getText(), containsString(reply.message))) } fun assertReplyDisplayed(reply: DiscussionEntry) { @@ -152,9 +149,8 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { fun assertFavoritingEnabled(reply: DiscussionEntry) { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) - } - catch(t: Throwable) { + .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) + } catch (t: Throwable) { assertTrue("Favoriting icon is disabled", false) } } @@ -162,31 +158,28 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { fun assertFavoritingDisabled(reply: DiscussionEntry) { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) + .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) // We shouldn't reach this point if the favoriting icon is disabled -- we should throw assertTrue("Favoriting icon is enabled", false) - } - catch(t: Throwable) { - } + } catch (_: Throwable) {} } fun clickLikeOnEntry(reply: DiscussionEntry) { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) - .perform(webClick()) + .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) + .perform(webClick()) } fun assertLikeCount(reply: DiscussionEntry, count: Int, refreshesAllowed: Int = 0) { - if(count > 0) { + if (count > 0) { - for(i in 0..refreshesAllowed-1) { + for (i in 0 until refreshesAllowed) { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) - .check(webMatches(getText(), containsString(count.toString()))) + .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) + .check(webMatches(getText(), containsString(count.toString()))) return - } - catch(t: Throwable) { + } catch (t: Throwable) { refresh() } } @@ -194,52 +187,48 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { // If we haven't verified our info by now, let's make one last call to either // (1) succeed or (2) throw a sensible error. onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) - .check(webMatches(getText(), containsString(count.toString()))) - } - else { + .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) + .check(webMatches(getText(), containsString(count.toString()))) + } else { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) + .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) assertTrue("Didn't expect to see like count with 0 count", false) - } - catch(t: Throwable) { } - + } catch (_: Throwable) {} } } fun assertReplyAttachment(reply: DiscussionEntry) { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "attachments_${reply.id}")) - } - catch(t: Throwable) { + .withElement(findElement(Locator.CLASS_NAME, "attachments_${reply.id}")) + } catch (t: Throwable) { assertTrue("Discussion entry did not have an attachment", false) } } - fun previewAndCheckReplyAttachment(reply: DiscussionEntry, vararg checks : WebViewTextCheck) { + fun previewAndCheckReplyAttachment(reply: DiscussionEntry, vararg checks: WebViewTextCheck) { // Sometimes clicking the attachment logo fails to do anything. // We'll give it 3 chances. var triesRemaining = 3; - while(!isElementDisplayed(R.id.canvasWebViewWrapper) && triesRemaining > 0) { - if(triesRemaining < 3) { + while (!isElementDisplayed(R.id.canvasWebViewWrapper) && triesRemaining > 0) { + if (triesRemaining < 3) { refresh() // Maybe web content was incorrectly rendered? Try again sleep(1500) // Allow webview some time to render } onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElementRepeat(findElement(Locator.CLASS_NAME, "attachments_${reply.id}"), 20) - .perform(webClick()) + .withElementRepeat(findElement(Locator.CLASS_NAME, "attachments_${reply.id}"), 20) + .perform(webClick()) triesRemaining -= 1 } assertTrue("FAILED to bring up reply attachment", isElementDisplayed(R.id.canvasWebViewWrapper)); - for(check in checks) { + for (check in checks) { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.canvasWebViewWrapper)) - .withElement(findElement(check.locatorType, check.locatorValue)) - .check(webMatches(getText(), containsString(check.textValue))) + .withElement(findElement(check.locatorType, check.locatorValue)) + .check(webMatches(getText(), containsString(check.textValue))) } Espresso.pressBack() @@ -250,14 +239,14 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { // It appears that sometimes the click to reply doesn't work. // Let's give it 3 chances. var triesRemaining = 3 - while(!isElementDisplayed(R.id.rce_webView) && triesRemaining > 0) { - if(triesRemaining < 3) { + while (!isElementDisplayed(R.id.rce_webView) && triesRemaining > 0) { + if (triesRemaining < 3) { refresh() // maybe the html was rendered badly and needs refreshing? sleep(2000) // A little time for the webview to render } onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElementRepeat(findElement(Locator.ID, "reply_${reply.id}"), 20) - .perform(webClick()) + .withElementRepeat(findElement(Locator.ID, "reply_${reply.id}"), 20) + .perform(webClick()) triesRemaining -= 1 } @@ -278,10 +267,10 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { */ fun previewAndCheckMainAttachment(vararg checks: WebViewTextCheck) { onView(withId(R.id.attachmentIcon)).click() - for(check in checks) { + for (check in checks) { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.canvasWebViewWrapper)) - .withElement(findElement(check.locatorType, check.locatorValue)) - .check(webMatches(getText(), containsString(check.textValue))) + .withElement(findElement(check.locatorType, check.locatorValue)) + .check(webMatches(getText(), containsString(check.textValue))) } Espresso.pressBack() } @@ -298,7 +287,7 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { */ fun waitForUnreadIndicatorToDisappear(reply: DiscussionEntry) { repeat(10) { - if(!isUnreadIndicatorVisible(reply)) return + if (!isUnreadIndicatorVisible(reply)) return sleep(1000) } @@ -309,24 +298,23 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { onView(withId(R.id.pointsTextView)).check(matches(containsTextCaseInsensitive(points))) } - private fun isUnreadIndicatorVisible(reply: DiscussionEntry) : Boolean { - try { + fun assertPointsPossibleNotDisplayed() { + onView(withId(R.id.pointsTextView)).assertNotDisplayed() + } + + private fun isUnreadIndicatorVisible(reply: DiscussionEntry): Boolean { + return try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.ID, "unread_indicator_${reply.id}")) - .withElement(findElement(Locator.CLASS_NAME, "unread")) - return true - } - catch(t: Throwable) { - return false + .withElement(findElement(Locator.ID, "unread_indicator_${reply.id}")) + .withElement(findElement(Locator.CLASS_NAME, "unread")) + true + } catch (t: Throwable) { + false } } - fun scrollToTop() { + private fun scrollToTop() { onView(allOf(withId(R.id.swipeRefreshLayout), isDisplayingAtLeast(10))) - .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) + .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) } } - - - - diff --git a/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt index add2fbdc0f..104237e0d9 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt @@ -31,6 +31,7 @@ import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import com.google.android.material.snackbar.Snackbar import com.instructure.canvasapi2.StatusCallback +import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.managers.DiscussionManager import com.instructure.canvasapi2.managers.DiscussionManager.deleteDiscussionEntry import com.instructure.canvasapi2.managers.GroupManager @@ -97,6 +98,8 @@ class DiscussionDetailsFragment : ParentFragment(), Bookmarkable { private var isNestedDetail: Boolean by BooleanArg(default = false, key = IS_NESTED_DETAIL) private val groupDiscussion: Boolean by BooleanArg(default = false, key = GROUP_DISCUSSION) + private var courseSettings: CourseSettings? = null + private var scrollPosition: Int = 0 private var authenticatedSessionURL: String? = null @@ -550,6 +553,8 @@ class DiscussionDetailsFragment : ParentFragment(), Bookmarkable { // Do we have a discussion topic header? if not fetch it, or if forceRefresh is true force a fetch + courseSettings = CourseManager.getCourseSettingsAsync(canvasContext.id, forceRefresh).await().dataOrNull + if (forceRefresh) { val discussionTopicHeaderId = if (discussionTopicHeaderId == 0L && discussionTopicHeader.id != 0L) discussionTopicHeader.id else discussionTopicHeaderId if (!updateToGroupIfNecessary()) { @@ -602,9 +607,9 @@ class DiscussionDetailsFragment : ParentFragment(), Bookmarkable { delay(300) discussionsScrollView.post { if (topLevelReplyPosted) { - discussionsScrollView?.fullScroll(ScrollView.FOCUS_DOWN) + discussionsScrollView.fullScroll(ScrollView.FOCUS_DOWN) } else { - discussionsScrollView?.scrollTo(0, scrollPosition) + discussionsScrollView.scrollTo(0, scrollPosition) } } } @@ -729,7 +734,7 @@ class DiscussionDetailsFragment : ParentFragment(), Bookmarkable { private fun setupAssignmentDetails(assignment: Assignment) = with(binding) { with(assignment) { - pointsTextView.setVisible() + pointsTextView.setVisible(!courseSettings?.restrictQuantitativeData.orDefault()) // Points possible pointsTextView.text = resources.getQuantityString( R.plurals.quantityPointsAbbreviated, From 551cfd63ee59cd86ffc87c69f5c2170eae1b84e8 Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Tue, 8 Aug 2023 15:11:11 +0200 Subject: [PATCH 24/61] [MBL-16921][Student] Modules LGO refs: MBL-16921 affects: Student release note: none --- .../ui/interaction/ModuleInteractionTest.kt | 281 ++++++++++-------- .../student/ui/pages/ModulesPage.kt | 39 ++- .../adapter/ModuleListRecyclerAdapter.kt | 25 +- .../student/holders/ModuleViewHolder.kt | 5 +- .../canvas/espresso/mockCanvas/MockCanvas.kt | 6 +- 5 files changed, 198 insertions(+), 158 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt index 3eabdaf9f9..c35844fb1b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt @@ -18,34 +18,12 @@ package com.instructure.student.ui.interaction import android.text.Html import androidx.test.espresso.Espresso import androidx.test.espresso.web.webdriver.Locator -import com.instructure.canvas.espresso.mockCanvas.MockCanvas -import com.instructure.canvas.espresso.mockCanvas.addAssignment -import com.instructure.canvas.espresso.mockCanvas.addDiscussionTopicToCourse -import com.instructure.canvas.espresso.mockCanvas.addFileToCourse -import com.instructure.canvas.espresso.mockCanvas.addItemToModule -import com.instructure.canvas.espresso.mockCanvas.addLTITool -import com.instructure.canvas.espresso.mockCanvas.addModuleToCourse -import com.instructure.canvas.espresso.mockCanvas.addPageToCourse -import com.instructure.canvas.espresso.mockCanvas.addQuestionToQuiz -import com.instructure.canvas.espresso.mockCanvas.addQuizToCourse -import com.instructure.canvas.espresso.mockCanvas.init -import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.models.DiscussionTopicHeader -import com.instructure.canvasapi2.models.LockInfo -import com.instructure.canvasapi2.models.LockedModule -import com.instructure.canvasapi2.models.ModuleObject -import com.instructure.canvasapi2.models.Page -import com.instructure.canvasapi2.models.Quiz -import com.instructure.canvasapi2.models.QuizAnswer -import com.instructure.canvasapi2.models.Tab +import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvasapi2.models.* import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.panda_annotations.FeatureCategory -import com.instructure.panda_annotations.Priority -import com.instructure.panda_annotations.SecondaryFeatureCategory -import com.instructure.panda_annotations.TestCategory -import com.instructure.panda_annotations.TestMetaData +import com.instructure.panda_annotations.* import com.instructure.student.R import com.instructure.student.ui.pages.WebViewTextCheck import com.instructure.student.ui.utils.StudentTest @@ -123,7 +101,7 @@ class ModuleInteractionTest : StudentTest() { val module = data.courseModules[course1.id]!!.first() // click the external url module item - modulesPage.clickModuleItem(module,externalUrl) + modulesPage.clickModuleItem(module, externalUrl) // Not much we can test here, as it is an external URL, but testModules_navigateToNextAndPreviousModuleItems // will test that the module name and module item name are displayed correctly. canvasWebViewPage.checkWebViewURL("https://www.google.com") @@ -139,7 +117,7 @@ class ModuleInteractionTest : StudentTest() { val module = data.courseModules[course1.id]!!.first() // Click the file module and verify that the file appears - modulesPage.clickModuleItem(module,fileName, R.id.openButton) + modulesPage.clickModuleItem(module, fileName, R.id.openButton) canvasWebViewPage.waitForWebView() canvasWebViewPage.runTextChecks(fileCheck!!) } @@ -163,9 +141,9 @@ class ModuleInteractionTest : StudentTest() { // Also, just use the first 10 chars because you risk encountering multiple-newlines // (which show as single newlines in webview, or even no-newlines if at the end // of the string) if you go much longer - var expectedBody = Html.fromHtml(page!!.body!!).toString().substring(0,10) + var expectedBody = Html.fromHtml(page!!.body!!).toString().substring(0, 10) canvasWebViewPage.runTextChecks( - WebViewTextCheck(Locator.ID, "content", expectedBody) + WebViewTextCheck(Locator.ID, "content", expectedBody) ) } @@ -208,11 +186,11 @@ class ModuleInteractionTest : StudentTest() { // the initial assertModuleItemDisplayed() would expand the module if it was not expanded // already. modulesPage.assertModuleDisplayed(module) - modulesPage.assertModuleItemDisplayed(module,firstModuleItem.title!!) + modulesPage.assertModuleItemDisplayed(module, firstModuleItem.title!!) modulesPage.clickModule(module) modulesPage.assertModuleItemNotDisplayed(firstModuleItem.title!!) modulesPage.clickModule(module) - modulesPage.assertModuleItemDisplayed(module,firstModuleItem.title!!) + modulesPage.assertModuleItemDisplayed(module, firstModuleItem.title!!) } @@ -228,10 +206,10 @@ class ModuleInteractionTest : StudentTest() { // For each module item, go into the module detail page, click the back button, // and verify that we've returned to the module list page. - for(moduleItem in module.items) { - modulesPage.clickModuleItem(module,moduleItem.title!!) + for (moduleItem in module.items) { + modulesPage.clickModuleItem(module, moduleItem.title!!) Espresso.pressBack() - modulesPage.assertModuleItemDisplayed(module,moduleItem.title!!) + modulesPage.assertModuleItemDisplayed(module, moduleItem.title!!) } } @@ -248,25 +226,23 @@ class ModuleInteractionTest : StudentTest() { // Iterate through the module items, starting at the first val moduleItemList = module.items - modulesPage.clickModuleItem(module,moduleItemList[0].title!!) + modulesPage.clickModuleItem(module, moduleItemList[0].title!!) var moduleIndex = 0; // we start here - while(moduleIndex < moduleItemList.count()) { + while (moduleIndex < moduleItemList.count()) { val moduleItem = moduleItemList[moduleIndex] // Make sure that the previous button is appropriately displayed/gone - if(moduleIndex == 0) { + if (moduleIndex == 0) { moduleProgressionPage.assertPreviousButtonInvisible() - } - else { + } else { moduleProgressionPage.assertPreviousButtonDisplayed() } // Make sure that the next button is appropriately displayed/gone - if(moduleIndex == moduleItemList.count() - 1) { + if (moduleIndex == moduleItemList.count() - 1) { moduleProgressionPage.assertNextButtonInvisible() - } - else { + } else { moduleProgressionPage.assertNextButtonDisplayed() } @@ -278,12 +254,12 @@ class ModuleInteractionTest : StudentTest() { // Let's navigate to our next page moduleIndex += 1 - if(moduleIndex < moduleItemList.count()) { + if (moduleIndex < moduleItemList.count()) { moduleProgressionPage.clickNextButton() } } - if(moduleItemList.count() > 1) { + if (moduleItemList.count() > 1) { // Let's make sure that the "previous" button works as well. moduleProgressionPage.clickPreviousButton() val moduleItem = moduleItemList[moduleItemList.count() - 2] @@ -303,26 +279,27 @@ class ModuleInteractionTest : StudentTest() { // Let's add a second module that has the first one as a prerequisite val module2 = data.addModuleToCourse( - course = course1, - moduleName = "Prereq Module", - prerequisiteIds = longArrayOf(module.id), - state = ModuleObject.State.Locked.toString() + course = course1, + moduleName = "Prereq Module", + prerequisiteIds = longArrayOf(module.id), + state = ModuleObject.State.Locked.toString() ) // And let's add an assignment to the new module var unavailableAssignment = data.addAssignment( - courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, - // Man, this is a bit hokey, but it's what I had to do to get the assignment to show - // up as unavailable in the assignment details page - lockInfo = LockInfo( - modulePrerequisiteNames = arrayListOf(module.name!!), - contextModule = LockedModule(name = module.name!!) ) + courseId = course1.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + // Man, this is a bit hokey, but it's what I had to do to get the assignment to show + // up as unavailable in the assignment details page + lockInfo = LockInfo( + modulePrerequisiteNames = arrayListOf(module.name!!), + contextModule = LockedModule(name = module.name!!) + ) ) data.addItemToModule( - course = course1, - moduleId = module2.id, - item = unavailableAssignment + course = course1, + moduleId = module2.id, + item = unavailableAssignment ) // Refresh to get module list update, select module2, and assert that unavailableAssignment is locked @@ -343,20 +320,20 @@ class ModuleInteractionTest : StudentTest() { // Let's add a second module with a lockUntil setting val module2 = data.addModuleToCourse( - course = course1, - moduleName = "Locked Module", - unlockAt = 2.days.fromNow.iso8601 + course = course1, + moduleName = "Locked Module", + unlockAt = 2.days.fromNow.iso8601 ) // And let's create an assignment and add it to the "locked" module. val lockedAssignment = data.addAssignment( - courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + courseId = course1.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY ) data.addItemToModule( - course = course1, - moduleId = module2.id, - item = lockedAssignment + course = course1, + moduleId = module2.id, + item = lockedAssignment ) // Refresh to get module list update, then assert that module2 is locked @@ -366,17 +343,73 @@ class ModuleInteractionTest : StudentTest() { modulesPage.assertAssignmentLocked(lockedAssignment, course1) } + // Show possible points for assignments in modules if not restricted + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.MODULES, TestCategory.INTERACTION, false) + fun testModules_showPossiblePointsIfNotRestricted() { + val data = getToCourseModules(studentCount = 1, courseCount = 1) + val course = data.courses.values.first() + val module = data.courseModules[course.id]!!.first() + + data.courseSettings[course.id] = CourseSettings(restrictQuantitativeData = false) + + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + pointsPossible = 10 + ) + + data.addItemToModule( + course = course, + moduleId = module.id, + item = assignment, + moduleContentDetails = ModuleContentDetails(pointsPossible = assignment.pointsPossible.toString()) + ) + + modulesPage.refresh() + modulesPage.assertPossiblePointsDisplayed(assignment.pointsPossible.toInt().toString()) + } + + // Hide possible points for assignments in modules if restricted + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.MODULES, TestCategory.INTERACTION, false) + fun testModules_hidePossiblePointsIfRestricted() { + val data = getToCourseModules(studentCount = 1, courseCount = 1) + val course = data.courses.values.first() + val module = data.courseModules[course.id]!!.first() + + data.courseSettings[course.id] = CourseSettings(restrictQuantitativeData = true) + + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + pointsPossible = 10 + ) + + data.addItemToModule( + course = course, + moduleId = module.id, + item = assignment, + moduleContentDetails = ModuleContentDetails(pointsPossible = assignment.pointsPossible.toString()) + ) + + modulesPage.refresh() + modulesPage.assertPossiblePointsNotDisplayed(assignment.name.orEmpty()) + } + // Mock a specified number of students and courses, add some assorted assignments, discussions, etc... // in the form of module items, and navigate to the modules page of the course private fun getToCourseModules( - studentCount: Int = 1, - courseCount: Int = 1): MockCanvas { + studentCount: Int = 1, + courseCount: Int = 1 + ): MockCanvas { // Basic info val data = MockCanvas.init( - studentCount = studentCount, - courseCount = courseCount, - favoriteCourseCount = courseCount) + studentCount = studentCount, + courseCount = courseCount, + favoriteCourseCount = courseCount + ) // Add a course tab val course1 = data.courses.values.first() @@ -386,32 +419,32 @@ class ModuleInteractionTest : StudentTest() { // Create a module val module = data.addModuleToCourse( - course = course1, - moduleName = "Big Module" + course = course1, + moduleName = "Big Module" ) // Create a discussion and add it as a module item topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion in module", - topicDescription = "In. A. Module." + course = course1, + user = user1, + topicTitle = "Discussion in module", + topicDescription = "In. A. Module." ) data.addItemToModule( - course = course1, - moduleId = module.id, - item = topicHeader!! + course = course1, + moduleId = module.id, + item = topicHeader!! ) // Create an assignment and add it as a module item assignment = data.addAssignment( - courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + courseId = course1.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY ) data.addItemToModule( - course = course1, - moduleId = module.id, - item = assignment!! + course = course1, + moduleId = module.id, + item = assignment!! ) // Create a page and add it as a module item @@ -423,74 +456,74 @@ class ModuleInteractionTest : StudentTest() { url = URLEncoder.encode("Page In Course", "UTF-8") ) data.addItemToModule( - course = course1, - moduleId = module.id, - item = page!! + course = course1, + moduleId = module.id, + item = page!! ) // Create a file and add it as a module item val fileContent = "

A Heading

" - fileCheck = WebViewTextCheck(Locator.ID,"heading1","A Heading") + fileCheck = WebViewTextCheck(Locator.ID, "heading1", "A Heading") val fileId = data.addFileToCourse( - courseId = course1.id, - displayName = fileName, - fileContent = fileContent, - contentType = "text/html" + courseId = course1.id, + displayName = fileName, + fileContent = fileContent, + contentType = "text/html" ) val rootFolderId = data.courseRootFolders[course1.id]!!.id - val fileFolder = data.folderFiles[rootFolderId]?.find {it.id == fileId} + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } data.addItemToModule( - course = course1, - moduleId = module.id, - item = fileFolder!! + course = course1, + moduleId = module.id, + item = fileFolder!! ) // Create an external URL and add it as a module item data.addItemToModule( - course = course1, - moduleId = module.id, - item = externalUrl + course = course1, + moduleId = module.id, + item = externalUrl ) // Create a quiz and add it as a module item quiz = data.addQuizToCourse( - course = course1 + course = course1 ) data.addQuestionToQuiz( - course = course1, - quizId = quiz!!.id, - questionName = "Math 1", - questionText = "What is 2 + 5?", - questionType = "multiple_choice_question", - answers = arrayOf( - QuizAnswer(answerText = "7"), - QuizAnswer(answerText = "25"), - QuizAnswer(answerText = "-7") - ) + course = course1, + quizId = quiz!!.id, + questionName = "Math 1", + questionText = "What is 2 + 5?", + questionType = "multiple_choice_question", + answers = arrayOf( + QuizAnswer(answerText = "7"), + QuizAnswer(answerText = "25"), + QuizAnswer(answerText = "-7") + ) ) data.addQuestionToQuiz( - course = course1, - quizId = quiz!!.id, - questionName = "Math 2", - questionText = "Pi is greater than the square root of 2", - questionType = "true_false_question" + course = course1, + quizId = quiz!!.id, + questionName = "Math 2", + questionText = "Pi is greater than the square root of 2", + questionType = "true_false_question" ) data.addQuestionToQuiz( - course = course1, - quizId = quiz!!.id, - questionName = "Math 3", - questionText = "Write an essay on why math is so awesome", - questionType = "essay_question" + course = course1, + quizId = quiz!!.id, + questionName = "Math 3", + questionText = "Write an essay on why math is so awesome", + questionType = "essay_question" ) data.addItemToModule( - course = course1, - moduleId = module.id, - item = quiz!! + course = course1, + moduleId = module.id, + item = quiz!! ) val ltiTool = data.addLTITool("Google Drive", "http://google.com", course1, 1234L) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt index 402af186dc..b6e915e336 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt @@ -28,18 +28,8 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.ModuleObject import com.instructure.dataseeding.model.ModuleApiModel -import com.instructure.espresso.RecyclerViewItemCountAssertion -import com.instructure.espresso.assertDisplayed -import com.instructure.espresso.click -import com.instructure.espresso.page.BasePage -import com.instructure.espresso.page.onView -import com.instructure.espresso.page.plus -import com.instructure.espresso.page.withAncestor -import com.instructure.espresso.page.withDescendant -import com.instructure.espresso.page.withId -import com.instructure.espresso.page.withText -import com.instructure.espresso.scrollTo -import com.instructure.espresso.waitForCheck +import com.instructure.espresso.* +import com.instructure.espresso.page.* import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.R import org.hamcrest.Matchers.allOf @@ -68,8 +58,8 @@ class ModulesPage : BasePage(R.id.modulesPage) { // Asserts that an assignment (presumably from a module) is locked fun assertAssignmentLocked(assignment: Assignment, course: Course) { val matcher = allOf( - hasSibling(withText(assignment.name)), - withId(R.id.indicator) + hasSibling(withText(assignment.name)), + withId(R.id.indicator) ) // Scroll to the assignment @@ -96,13 +86,21 @@ class ModulesPage : BasePage(R.id.modulesPage) { onView(withText(itemTitle)).check(doesNotExist()) } + fun assertPossiblePointsDisplayed(points: String) { + onView(withId(R.id.points) + withText("$points pts")).assertDisplayed() + } + + fun assertPossiblePointsNotDisplayed(name: String) { + onView(withParent(hasSibling(withChild(withId(R.id.title) + withText(name)))) + withId(R.id.points)).assertNotDisplayed() + } + /** * It is occasionally the case that we need to click a few extra buttons to get "fully" into * the item. Thus the [extraClickIds] vararg param. */ fun clickModuleItem(module: ModuleObject, itemTitle: String, vararg extraClickIds: Int) { assertAndClickModuleItem(module.name!!, itemTitle, true) - for(extraClickId in extraClickIds) { + for (extraClickId in extraClickIds) { onView(allOf(withId(extraClickId), isDisplayed())).click() } } @@ -111,19 +109,18 @@ class ModulesPage : BasePage(R.id.modulesPage) { fun assertAndClickModuleItem(moduleName: String, itemTitle: String, clickItem: Boolean = false) { try { scrollRecyclerView(R.id.listView, withText(itemTitle)) - if(clickItem) { + if (clickItem) { onView(withText(itemTitle)).click() } - } - catch(ex: Exception) { - when(ex) { + } catch (ex: Exception) { + when (ex) { is NoMatchingViewException, is PerformException -> { // Maybe our module hasn't been expanded. Click the module and try again. val moduleMatcher = withText(moduleName) scrollRecyclerView(R.id.listView, moduleMatcher) onView(moduleMatcher).click() scrollRecyclerView(R.id.listView, withText(itemTitle)) - if(clickItem) { + if (clickItem) { onView(withText(itemTitle)).click() } } @@ -137,7 +134,7 @@ class ModulesPage : BasePage(R.id.modulesPage) { } fun refresh() { - onView(allOf(withId(R.id.swipeRefreshLayout),isDisplayed())).perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(5))) + onView(allOf(withId(R.id.swipeRefreshLayout), isDisplayed())).perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(5))) } fun clickOnModuleExpandCollapseIcon(moduleName: String) { diff --git a/apps/student/src/main/java/com/instructure/student/adapter/ModuleListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/ModuleListRecyclerAdapter.kt index cc20e5292d..ca0bc80e2f 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/ModuleListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/ModuleListRecyclerAdapter.kt @@ -28,6 +28,7 @@ import android.view.WindowManager import android.widget.ProgressBar import androidx.recyclerview.widget.RecyclerView import com.instructure.canvasapi2.StatusCallback +import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.managers.ModuleManager import com.instructure.canvasapi2.managers.TabManager import com.instructure.canvasapi2.models.* @@ -41,8 +42,8 @@ import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.pandarecycler.interfaces.ViewHolderHeaderClicked import com.instructure.pandarecycler.util.GroupSortedList import com.instructure.pandarecycler.util.Types -import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Utils +import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.R import com.instructure.student.holders.ModuleEmptyViewHolder @@ -66,7 +67,9 @@ open class ModuleListRecyclerAdapter( private val mModuleItemCallbacks = HashMap() private var mModuleObjectCallback: StatusCallback>? = null - private var checkCourseTabsJob: Job? = null + private var getInitialDataJob: Job? = null + + private var courseSettings: CourseSettings? = null /* For testing purposes only */ protected constructor(context: Context) : this(CanvasContext.defaultCanvasContext(), context, false,null) // Callback not needed for testing, cast to null @@ -108,8 +111,10 @@ open class ModuleListRecyclerAdapter( val groupItemCount = getGroupItemCount(moduleObject) val itemPosition = storedIndexOfItem(moduleObject, moduleItem) - (holder as ModuleViewHolder).bind(moduleObject, moduleItem, context, adapterToFragmentCallback, courseColor, - itemPosition == 0, itemPosition == groupItemCount - 1) + (holder as ModuleViewHolder).bind( + moduleObject, moduleItem, context, adapterToFragmentCallback, courseColor, itemPosition == 0, + itemPosition == groupItemCount - 1, courseSettings?.restrictQuantitativeData.orDefault() + ) } } @@ -140,7 +145,7 @@ open class ModuleListRecyclerAdapter( override fun refresh() { shouldExhaustPagination = false mModuleItemCallbacks.clear() - checkCourseTabsJob?.cancel() + getInitialDataJob?.cancel() collapseAll() super.refresh() } @@ -148,7 +153,7 @@ open class ModuleListRecyclerAdapter( override fun cancel() { mModuleItemCallbacks.values.forEach { it.cancel() } mModuleObjectCallback?.cancel() - checkCourseTabsJob?.cancel() + getInitialDataJob?.cancel() } override fun contextReady() { @@ -355,9 +360,11 @@ open class ModuleListRecyclerAdapter( } override fun loadFirstPage() { - checkCourseTabsJob = tryWeave { - val tabs = awaitApi> { TabManager.getTabs(courseContext, it, isRefresh) } - .filter { !(it.isExternal && it.isHidden) } + getInitialDataJob = tryWeave { + val tabs = awaitApi { TabManager.getTabs(courseContext, it, isRefresh) } + .filter { !(it.isExternal && it.isHidden) } + + courseSettings = CourseManager.getCourseSettingsAsync(courseContext.id, isRefresh).await().dataOrNull // We only want to show modules if its a course nav option OR set to as the homepage if (tabs.find { it.tabId == "modules" } != null || (courseContext as Course).homePage?.apiString == "modules") { diff --git a/apps/student/src/main/java/com/instructure/student/holders/ModuleViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/ModuleViewHolder.kt index 69c8688478..28c299efd5 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/ModuleViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/ModuleViewHolder.kt @@ -44,7 +44,8 @@ class ModuleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { adapterToFragmentCallback: ModuleAdapterToFragmentCallback?, courseColor: Int, isFirstItem: Boolean, - isLastItem: Boolean + isLastItem: Boolean, + restrictQuantitativeData: Boolean ) = with(ViewholderModuleBinding.bind(itemView)) { val isLocked = ModuleUtility.isGroupLocked(moduleObject) @@ -146,7 +147,7 @@ class ModuleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { hasDate = false } val pointsPossible = details.pointsPossible - if (pointsPossible.isValid()) { + if (pointsPossible.isValid() && !restrictQuantitativeData) { points.text = context.getString( R.string.totalPoints, NumberHelper.formatDecimal(pointsPossible.toDouble(), 2, true) diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt index 59725c6e14..e82206a41b 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt @@ -1482,7 +1482,8 @@ fun MockCanvas.addItemToModule( course: Course, moduleId: Long, item: Any, - published: Boolean = true + published: Boolean = true, + moduleContentDetails: ModuleContentDetails? = null ) : ModuleItem { // Placeholders for itemType and itemTitle values that we will compute below @@ -1544,7 +1545,8 @@ fun MockCanvas.addItemToModule( // I don't really know if these two should be the same, but I needed // htmlUrl populated in order to get external url module items to work. url = itemUrl, - htmlUrl = itemUrl + htmlUrl = itemUrl, + moduleDetails = moduleContentDetails ) // Copy/update/replace the module From 1757024cdc6cf22a6e5464741fe583b99728684c Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Wed, 9 Aug 2023 10:12:47 +0200 Subject: [PATCH 25/61] [MBL-16911][Student] Assignments LGO (#2094) refs: MBL-16911 affects: Student release note: none * Assignment list lgo. * Assignment details LGO. * Assignment list tests. * Assignment details unit tests. * Assignment details interaction tests. --- .../AssignmentDetailsInteractionTest.kt | 216 +++++++++++++++- .../AssignmentListInteractionTest.kt | 182 ++++++++++++- .../student/ui/pages/AssignmentDetailsPage.kt | 26 ++ .../student/ui/pages/AssignmentListPage.kt | 12 + .../AssignmentListRecyclerAdapter.kt | 22 +- .../AssignmentDetailsViewModel.kt | 20 +- .../gradecellview/GradeCellViewData.kt | 242 ++++++++++-------- .../student/holders/AssignmentViewHolder.kt | 8 +- .../instructure/student/util/BinderUtils.kt | 8 +- .../layout/fragment_assignment_details.xml | 2 + .../res/layout/viewholder_card_generic.xml | 1 + .../AssignmentDetailsViewModelTest.kt | 41 ++- .../gradecellview/GradeCellViewDataTest.kt | 91 ++++++- .../canvas/espresso/mockCanvas/MockCanvas.kt | 6 +- libs/pandares/src/main/res/values/strings.xml | 1 + 15 files changed, 725 insertions(+), 153 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt index c0763e0e9f..8be34b0815 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt @@ -21,6 +21,7 @@ import com.instructure.canvas.espresso.mockCanvas.addAssignmentsToGroups import com.instructure.canvas.espresso.mockCanvas.addSubmissionForAssignment import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CourseSettings import com.instructure.canvasapi2.utils.toApiString import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority @@ -33,7 +34,7 @@ import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Assert.assertNotNull import org.junit.Test -import java.util.* +import java.util.Calendar @HiltAndroidTest class AssignmentDetailsInteractionTest : StudentTest() { @@ -72,7 +73,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testSubmissionStatus_Missing() { // Test clicking on the Assignment item in the Assignment List to load the Assignment Details Page - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val assignmentWithoutSubmissionEntry = assignmentList.filter { it.value.submission == null && it.value.dueAt != null && !it.value.isSubmitted } @@ -85,7 +87,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testSubmissionStatus_NotSubmitted() { - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val assignmentWithoutSubmissionEntry = assignmentList.filter {it.value.submission == null && it.value.dueAt == null} val assignmentWithoutSubmission = assignmentWithoutSubmissionEntry.entries.first().value @@ -100,7 +103,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testDisplayToolbarTitles() { - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val testAssignment = assignmentList.entries.first().value val course = data.courses.values.first() @@ -115,7 +119,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @Test @TestMetaData(Priority.COMMON, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testDisplayBookmarMenu() { - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val testAssignment = assignmentList.entries.first().value @@ -128,7 +133,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testDisplayDueDate() { - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val calendar = Calendar.getInstance().apply { set(2023, 0, 31, 23, 59, 0) } val expectedDueDate = "January 31, 2023 11:59 PM" val course = data.courses.values.first() @@ -143,7 +149,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testNavigating_viewAssignmentDetails() { // Test clicking on the Assignment item in the Assignment List to load the Assignment Details Page - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val assignmentWithSubmissionEntry = assignmentList.filter {it.value.submission != null} val assignmentWithSubmission = assignmentWithSubmissionEntry.entries.first().value @@ -158,7 +165,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION) fun testNavigating_viewSubmissionDetailsWithSubmission() { // Test clicking on the Submission and Rubric button to load the Submission Details Page - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val assignmentWithSubmissionEntry = assignmentList.filter {it.value.submission != null} val assignmentWithSubmission = assignmentWithSubmissionEntry.entries.first().value @@ -173,7 +181,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION) fun testNavigating_viewSubmissionDetailsWithoutSubmission() { // Test clicking on the Submission and Rubric button to load the Submission Details Page - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val assignmentWithoutSubmissionEntry = assignmentList.filter {it.value.submission == null} val assignmentWithoutSubmission = assignmentWithoutSubmissionEntry.entries.first().value @@ -184,17 +193,187 @@ class AssignmentDetailsInteractionTest : StudentTest() { submissionDetailsPage.assertPageObjects() } - private fun goToAssignmentFromList(): MockCanvas { + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithoutQuantitativeRestriction() { + val data = setUpData() + val assignment = addAssignment(data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("B") + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 100 pts") + assignmentDetailsPage.assertScoreDisplayed("90") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("3.7") + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 100 pts") + assignmentDetailsPage.assertScoreDisplayed("90") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, "90", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeNotDisplayed() + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 100 pts") + assignmentDetailsPage.assertScoreDisplayed("90") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("EX") + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 100 pts") + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PERCENT, "90%", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("90%") + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 100 pts") + assignmentDetailsPage.assertScoreDisplayed("90") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("Complete") + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 0 pts") + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithQuantitativeRestriction() { + val data = setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("B") + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("3.7") + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, "90", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeNotDisplayed() + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("EX") + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PERCENT, "90%", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeNotDisplayed() + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("Complete") + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + private fun setUpData(restrictQuantitativeData: Boolean = false): MockCanvas { // Test clicking on the Submission and Rubric button to load the Submission Details Page val data = MockCanvas.init( studentCount = 1, courseCount = 1 ) + val course = data.courses.values.first() + + val newCourse = course + .copy(settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData)) + data.courses[course.id] = newCourse + + data.addAssignmentsToGroups(newCourse) + + return data + } + + private fun goToAssignmentList() { + val data = MockCanvas.data val course = data.courses.values.first() val student = data.students[0] val token = data.tokenFor(student)!! - val assignmentGroups = data.addAssignmentsToGroups(course) + val assignmentGroups = data.assignmentGroups[course.id]!! + tokenLogin(data.domain, token, student) routeTo("courses/${course.id}/assignments", data.domain) assignmentListPage.waitForPage() @@ -205,8 +384,21 @@ class AssignmentDetailsInteractionTest : StudentTest() { val assignmentWithoutSubmission = assignmentGroups.flatMap { it.assignments }.find {it.submission == null} assertNotNull("Expected at least one assignment with a submission", assignmentWithSubmission) assertNotNull("Expected at least one assignment without a submission", assignmentWithoutSubmission) + } - return data + private fun addAssignment(data: MockCanvas, gradingType: Assignment.GradingType, grade: String?, score: Double?, maxScore: Int, excused: Boolean = false): Assignment { + val course = data.courses.values.first() + val student = data.students.first() + + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", + pointsPossible = maxScore, + ) + + data.addSubmissionForAssignment(assignment.id, student.id, Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString, grade = grade, score = score, excused = excused) + return assignment } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt index c92de79225..8564fc53ed 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt @@ -17,8 +17,10 @@ package com.instructure.student.ui.interaction import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addSubmissionForAssignment import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CourseSettings import com.instructure.student.ui.utils.* import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority @@ -33,28 +35,32 @@ class AssignmentListInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) override fun displaysPageObjects() { - getToAssignmentsPage(0) + setUpData(0) + goToAssignmentsPage() assignmentListPage.assertPageObjects() } @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun displaysNoAssignmentsView() { - getToAssignmentsPage(0) + setUpData(0) + goToAssignmentsPage() assignmentListPage.assertDisplaysNoAssignmentsView() } @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun displaysAssignment() { - val assignment = getToAssignmentsPage()[0] + val assignment = setUpData()[0] + goToAssignmentsPage() assignmentListPage.assertHasAssignment(assignment) } @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun sortAssignmentsByTimeByDefault() { - val assignment = getToAssignmentsPage()[0] + val assignment = setUpData()[0] + goToAssignmentsPage() assignmentListPage.assertHasAssignment(assignment) assignmentListPage.assertSortByButtonShowsSortByTime() assignmentListPage.assertFindsUndatedAssignmentLabel() @@ -63,7 +69,8 @@ class AssignmentListInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun sortAssignmentsByTypeWhenTypeIsSelectedInTheDialog() { - val assignment = getToAssignmentsPage()[0] + val assignment = setUpData()[0] + goToAssignmentsPage() assignmentListPage.selectSortByType() @@ -71,33 +78,180 @@ class AssignmentListInteractionTest : StudentTest() { assignmentListPage.assertSortByButtonShowsSortByType() } - private fun getToAssignmentsPage(assignmentCount: Int = 1): List { + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "90/100 (B)") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "90/100 (3.7)") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, "90", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "90/100") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "EX/100") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PERCENT, "90%", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "90%") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "Complete") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "B") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "3.7") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, "90", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithoutGrade(assignment.name!!) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "Excused") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PERCENT, "90%", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithoutGrade(assignment.name!!) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "Complete") + } + + private fun setUpData(assignmentCount: Int = 1, restrictQuantitativeData: Boolean = false): List { val data = MockCanvas.init( - courseCount = 1, - favoriteCourseCount = 1, - studentCount = 1, - teacherCount = 1 + courseCount = 1, + favoriteCourseCount = 1, + studentCount = 1, + teacherCount = 1 ) val course = data.courses.values.first() - val student = data.students.first() + + val newCourse = course + .copy(settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData)) + data.courses[course.id] = newCourse val assignmentList = mutableListOf() repeat(assignmentCount) { val assignment = data.addAssignment( - courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY ) assignmentList.add(assignment) } + return assignmentList + } + + private fun goToAssignmentsPage() { + val data = MockCanvas.data + + val course = data.courses.values.first() + val student = data.students[0] + val token = data.tokenFor(student)!! tokenLogin(data.domain, token, student) dashboardPage.waitForRender() dashboardPage.selectCourse(course) courseBrowserPage.selectAssignments() - return assignmentList + } + + private fun addAssignment(data: MockCanvas, gradingType: Assignment.GradingType, grade: String?, score: Double?, maxScore: Int, excused: Boolean = false): Assignment { + val course = data.courses.values.first() + val student = data.students.first() + + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", + pointsPossible = maxScore, + ) + + data.addSubmissionForAssignment(assignment.id, student.id, Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString, grade = grade, score = score, excused = excused) + + return assignment } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt index 03620abba6..2abb658dbb 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt @@ -36,6 +36,7 @@ import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertContainsText import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertHasText +import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.clearText import com.instructure.espresso.click import com.instructure.espresso.page.BasePage @@ -94,6 +95,31 @@ open class AssignmentDetailsPage : BasePage(R.id.assignmentDetailsPage) { onView(allOf(withId(R.id.submissionStatus), withText(R.string.gradedSubmissionLabel))).scrollTo().assertDisplayed() } + fun assertGradeDisplayed(grade: String) { + onView(withId(R.id.gradeCell)).scrollTo().assertDisplayed() + onView(withId(R.id.grade)).scrollTo().assertContainsText(grade) + } + + fun assertGradeNotDisplayed() { + onView(withId(R.id.grade)).assertNotDisplayed() + } + + fun assertOutOfTextDisplayed(outOfText: String) { + onView(withId(R.id.outOf)).scrollTo().assertContainsText(outOfText) + } + + fun assertOutOfTextNotDisplayed() { + onView(withId(R.id.outOf)).assertNotDisplayed() + } + + fun assertScoreDisplayed(score: String) { + onView(withId(R.id.score)).scrollTo().assertContainsText(score) + } + + fun assertScoreNotDisplayed() { + onView(withId(R.id.score)).assertNotDisplayed() + } + fun assertAssignmentLocked() { onView(withId(R.id.lockedMessageTextView)).assertDisplayed() onView(withId(R.id.lockedMessageTextView)).check(matches(containsTextCaseInsensitive("this assignment is locked"))) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt index 0db8c30d0d..cb79a791e3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt @@ -72,6 +72,18 @@ class AssignmentListPage : BasePage(pageResId = R.id.assignmentListPage) { assertHasAssignmentCommon(assignment.name!!, assignment.dueAt, expectedGrade) } + fun assertAssignmentDisplayedWithGrade(assignmentName: String, gradeString: String) { + onView(withId(R.id.title) + withParent(R.id.textContainer) + withText(assignmentName)).assertDisplayed() + val pointsMatcher = withId(R.id.title) + withText(assignmentName) + onView(withId(R.id.points) + withParent(hasSibling(pointsMatcher))).assertHasText(gradeString) + } + + fun assertAssignmentDisplayedWithoutGrade(assignmentName: String) { + onView(withId(R.id.title) + withParent(R.id.textContainer) + withText(assignmentName)).assertDisplayed() + val pointsMatcher = withId(R.id.title) + withText(assignmentName) + onView(withId(R.id.points) + withParent(hasSibling(pointsMatcher))).assertNotDisplayed() + } + fun clickOnSearchButton() { onView(withId(R.id.search)).click() } diff --git a/apps/student/src/main/java/com/instructure/student/adapter/assignment/AssignmentListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/assignment/AssignmentListRecyclerAdapter.kt index 4102e566ac..0a39eb61f4 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/assignment/AssignmentListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/assignment/AssignmentListRecyclerAdapter.kt @@ -58,7 +58,9 @@ abstract class AssignmentListRecyclerAdapter ( private var assignmentGroupCallback: StatusCallback>? = null override var currentGradingPeriod: GradingPeriod? = null private var apiJob: WeaveJob? = null + private var settingsJob: WeaveJob? = null protected var assignmentGroups: List = emptyList() + private var restrictQuantitativeData = false var filter: AssignmentListFilter = AssignmentListFilter.ALL set(value) { @@ -130,9 +132,24 @@ abstract class AssignmentListRecyclerAdapter ( if changes are made here, check if they are needed in the other recycler adapters.*/ val course = canvasContext as Course + if (course.settings != null && !isRefresh) { + restrictQuantitativeData = course.settings?.restrictQuantitativeData ?: false + loadAssignmentsData(course) + } else { + settingsJob = tryWeave { + val settings = CourseManager.getCourseSettingsAsync(canvasContext.id, isRefresh).await().dataOrNull + restrictQuantitativeData = settings?.restrictQuantitativeData ?: false + loadAssignmentsData(course) + } catch { + loadAssignmentsData(course) + } + } + } + + private fun loadAssignmentsData(course: Course) { //This check is for the "all grading periods" option if (currentGradingPeriod != null && currentGradingPeriod!!.title != null - && currentGradingPeriod!!.title == context.getString(R.string.assignmentsListAllGradingPeriods)) { + && currentGradingPeriod!!.title == context.getString(R.string.assignmentsListAllGradingPeriods)) { loadAssignment() return } @@ -185,7 +202,7 @@ abstract class AssignmentListRecyclerAdapter ( assignmentGroup: AssignmentGroup, assignment: Assignment ) { - (holder as AssignmentViewHolder).bind(context, assignment, canvasContext.backgroundColor, adapterToAssignmentsCallback) + (holder as AssignmentViewHolder).bind(context, assignment, canvasContext.backgroundColor, adapterToAssignmentsCallback, restrictQuantitativeData) } override fun onBindEmptyHolder(holder: RecyclerView.ViewHolder, assignmentGroup: AssignmentGroup) { @@ -243,6 +260,7 @@ abstract class AssignmentListRecyclerAdapter ( override fun cancel() { super.cancel() apiJob?.cancel() + settingsJob?.cancel() } } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt b/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt index f6c9d61ccd..0a7f57a4ac 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt @@ -91,6 +91,7 @@ class AssignmentDetailsViewModel @Inject constructor( private var dbSubmission: DatabaseSubmission? = null private var isUploading = false + private var restrictQuantitativeData = false var assignment: Assignment? = null private set @@ -163,6 +164,7 @@ class AssignmentDetailsViewModel @Inject constructor( viewModelScope.launch { try { val courseResult = courseManager.getCourseWithGradeAsync(course?.id.orDefault(), forceNetwork).await().dataOrThrow + restrictQuantitativeData = courseResult.settings?.restrictQuantitativeData ?: false isObserver = courseResult.enrollments?.firstOrNull { it.isObserver } != null @@ -236,11 +238,15 @@ class AssignmentDetailsViewModel @Inject constructor( @Suppress("DEPRECATION") private suspend fun getViewData(assignment: Assignment, hasDraft: Boolean): AssignmentDetailsViewData { - val points = resources.getQuantityString( - R.plurals.quantityPointsAbbreviated, - assignment.pointsPossible.toInt(), - NumberHelper.formatDecimal(assignment.pointsPossible, 1, true) - ) + val points = if (restrictQuantitativeData) { + "" + } else { + resources.getQuantityString( + R.plurals.quantityPointsAbbreviated, + assignment.pointsPossible.toInt(), + NumberHelper.formatDecimal(assignment.pointsPossible, 1, true) + ) + } val assignmentState = AssignmentUtils2.getAssignmentState(assignment, assignment.submission, false) @@ -434,7 +440,8 @@ class AssignmentDetailsViewModel @Inject constructor( resources, colorKeeper.getOrGenerateColor(course), assignment, - assignment.submission + assignment.submission, + restrictQuantitativeData ), dueDate = due, submissionTypes = submissionTypes, @@ -466,6 +473,7 @@ class AssignmentDetailsViewModel @Inject constructor( colorKeeper.getOrGenerateColor(course), assignment, selectedSubmission, + restrictQuantitativeData, attempt?.isUploading.orDefault(), attempt?.isFailed.orDefault() ) diff --git a/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/gradecellview/GradeCellViewData.kt b/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/gradecellview/GradeCellViewData.kt index 9d0746a6f2..6a0bc2a4c4 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/gradecellview/GradeCellViewData.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/gradecellview/GradeCellViewData.kt @@ -43,20 +43,21 @@ data class GradeCellViewData( courseColor: ThemedColor, assignment: Assignment?, submission: Submission?, + restrictQuantitativeData: Boolean = false, uploading: Boolean = false, failed: Boolean = false ): GradeCellViewData { - return if (uploading) { - GradeCellViewData(courseColor, State.UPLOADING) - } else if (failed) { - GradeCellViewData(courseColor, State.FAILED) - } else if ( - assignment == null + val hideGrades = restrictQuantitativeData && assignment?.isGradingTypeQuantitative == true && submission?.excused != true + val emptyGradeCell = assignment == null || submission == null || (submission.submittedAt == null && !submission.isGraded) || assignment.gradingType == Assignment.NOT_GRADED_TYPE - ) { - GradeCellViewData( + || hideGrades + + return when { + uploading -> GradeCellViewData(courseColor, State.UPLOADING) + failed -> GradeCellViewData(courseColor, State.FAILED) + emptyGradeCell -> GradeCellViewData( courseColor = courseColor, state = State.EMPTY, gradeCellContentDescription = getContentDescriptionText( @@ -64,8 +65,7 @@ data class GradeCellViewData( resources.getString(R.string.submissionAndRubric) ) ) - } else if (submission.isSubmitted) { - GradeCellViewData( + submission!!.isSubmitted -> GradeCellViewData( courseColor = courseColor, state = State.SUBMITTED, gradeCellContentDescription = getContentDescriptionText( @@ -74,113 +74,139 @@ data class GradeCellViewData( resources.getString(R.string.submissionStatusSuccessSubtitle) ) ) - } else { - val pointsPossibleText = NumberHelper.formatDecimal(assignment.pointsPossible, 2, true) - val outOfText = resources.getString(R.string.outOfPointsAbbreviatedFormatted, pointsPossibleText) - val outOfContentDescriptionText = resources.getString(R.string.outOfPointsFormatted, pointsPossibleText) + else -> createGradedViewData(resources, courseColor, assignment!!, submission, restrictQuantitativeData) + } + } - if (submission.excused) { - GradeCellViewData( - courseColor = courseColor, - state = State.GRADED, - chartPercent = 1f, - showCompleteIcon = true, - grade = resources.getString(R.string.excused), - outOf = outOfText, - gradeCellContentDescription = getContentDescriptionText( - resources, - resources.getString(R.string.gradeExcused), - outOfContentDescriptionText - ) + private fun createGradedViewData( + resources: Resources, + courseColor: ThemedColor, + assignment: Assignment, + submission: Submission, + restrictQuantitativeData: Boolean + ): GradeCellViewData { + val pointsPossibleText = NumberHelper.formatDecimal(assignment.pointsPossible, 2, true) + val outOfText = if (restrictQuantitativeData) "" else resources.getString(R.string.outOfPointsAbbreviatedFormatted, pointsPossibleText) + val outOfContentDescriptionText = if (restrictQuantitativeData) "" else resources.getString(R.string.outOfPointsFormatted, pointsPossibleText) + + return if (submission.excused) { + GradeCellViewData( + courseColor = courseColor, + state = State.GRADED, + chartPercent = 1f, + showCompleteIcon = true, + grade = resources.getString(R.string.excused), + outOf = outOfText, + gradeCellContentDescription = getContentDescriptionText( + resources, + resources.getString(R.string.gradeExcused), + outOfContentDescriptionText ) - } else if (assignment.gradingType == Assignment.PASS_FAIL_TYPE) { - val isComplete = (submission.grade == "complete") - val grade = resources.getString(if (isComplete) R.string.gradeComplete else R.string.gradeIncomplete) - GradeCellViewData( - courseColor = courseColor, - state = State.GRADED, - chartPercent = 1f, - showCompleteIcon = isComplete, - showIncompleteIcon = !isComplete, - grade = grade, - outOf = outOfText, - gradeCellContentDescription = getContentDescriptionText( - resources, - grade, - outOfContentDescriptionText - ) + ) + } else if (assignment.gradingType == Assignment.PASS_FAIL_TYPE) { + val isComplete = (submission.grade == "complete") + val grade = resources.getString(if (isComplete) R.string.gradeComplete else R.string.gradeIncomplete) + GradeCellViewData( + courseColor = courseColor, + state = State.GRADED, + chartPercent = 1f, + showCompleteIcon = isComplete, + showIncompleteIcon = !isComplete, + grade = grade, + outOf = outOfText, + gradeCellContentDescription = getContentDescriptionText( + resources, + grade, + outOfContentDescriptionText ) - } else { - val score = NumberHelper.formatDecimal(submission.enteredScore, 2, true) - val chartPercent = (submission.enteredScore / assignment.pointsPossible).coerceIn(0.0, 1.0).toFloat() - // If grading type is Points, don't show the grade since we're already showing it as the score - var grade = if (assignment.gradingType != Assignment.POINTS_TYPE) submission.grade.orEmpty() else "" - // Google talkback fails hard on "minus", so we need to remove the dash and replace it with the word - val accessibleGradeString = getContentDescriptionForMinusGradeString(grade, resources) - // We also need the entire grade cell to be read in a reasonable fashion - val gradeCellContentDescription = when { - accessibleGradeString.isNotEmpty() -> resources.getString( - R.string.a11y_gradeCellContentDescriptionWithLetterGrade, - score, - outOfContentDescriptionText, - accessibleGradeString - ) - grade.isNotEmpty() -> resources.getString( - R.string.a11y_gradeCellContentDescriptionWithLetterGrade, - score, - outOfContentDescriptionText, - grade - ) - else -> resources.getString(R.string.a11y_gradeCellContentDescription, score, outOfContentDescriptionText) - } + System.lineSeparator() + resources.getString(R.string.a11y_gradeCellContentDescriptionHint) + ) + } else if (restrictQuantitativeData) { + // We can only reach this branch when the grading type is GPA or letter grade, so don't need to handle any other case + val grade = submission.grade ?: "" + val accessibleGradeString = getContentDescriptionForMinusGradeString(grade, resources) + val contentDescription = resources.getString( + R.string.a11y_gradeCellContentDescriptionLetterGradeOnly, + accessibleGradeString + ) + System.lineSeparator() + resources.getString(R.string.a11y_gradeCellContentDescriptionHint) + + GradeCellViewData( + courseColor = courseColor, + state = State.GRADED, + chartPercent = 1.0f, + showCompleteIcon = true, + grade = grade, + gradeCellContentDescription = contentDescription, + ) + } else { + val score = NumberHelper.formatDecimal(submission.enteredScore, 2, true) + val chartPercent = (submission.enteredScore / assignment.pointsPossible).coerceIn(0.0, 1.0).toFloat() + // If grading type is Points, don't show the grade since we're already showing it as the score + var grade = if (assignment.gradingType != Assignment.POINTS_TYPE) submission.grade.orEmpty() else "" + // Google talkback fails hard on "minus", so we need to remove the dash and replace it with the word + val accessibleGradeString = getContentDescriptionForMinusGradeString(grade, resources) + // We also need the entire grade cell to be read in a reasonable fashion + val gradeCellContentDescription = when { + accessibleGradeString.isNotEmpty() -> resources.getString( + R.string.a11y_gradeCellContentDescriptionWithLetterGrade, + score, + outOfContentDescriptionText, + accessibleGradeString + ) + grade.isNotEmpty() -> resources.getString( + R.string.a11y_gradeCellContentDescriptionWithLetterGrade, + score, + outOfContentDescriptionText, + grade + ) + else -> resources.getString(R.string.a11y_gradeCellContentDescription, score, outOfContentDescriptionText) + } + System.lineSeparator() + resources.getString(R.string.a11y_gradeCellContentDescriptionHint) - var latePenalty = "" - var finalGrade = "" + var latePenalty = "" + var finalGrade = "" - // Adjust for late penalty, if any - if (submission.pointsDeducted.orDefault() > 0.0) { - grade = "" // Grade will be shown in the 'final grade' text - val pointsDeducted = NumberHelper.formatDecimal(submission.pointsDeducted.orDefault(), 2, true) - latePenalty = resources.getString(R.string.latePenalty, pointsDeducted) - finalGrade = resources.getString(R.string.finalGradeFormatted, submission.grade) - } + // Adjust for late penalty, if any + if (submission.pointsDeducted.orDefault() > 0.0) { + grade = "" // Grade will be shown in the 'final grade' text + val pointsDeducted = NumberHelper.formatDecimal(submission.pointsDeducted.orDefault(), 2, true) + latePenalty = resources.getString(R.string.latePenalty, pointsDeducted) + finalGrade = resources.getString(R.string.finalGradeFormatted, submission.grade) + } - val stats = assignment.scoreStatistics?.let { stats -> - GradeCellViewState.GradeStats( - score = submission.score, - outOf = assignment.pointsPossible, - min = stats.min, - max = stats.max, - mean = stats.mean, - minText = resources.getString( - R.string.scoreStatisticsLow, - NumberHelper.formatDecimal(stats.min, 1, true) - ), - maxText = resources.getString( - R.string.scoreStatisticsHigh, - NumberHelper.formatDecimal(stats.max, 1, true) - ), - meanText = resources.getString( - R.string.scoreStatisticsMean, - NumberHelper.formatDecimal(stats.mean, 1, true) - ) + val stats = assignment.scoreStatistics?.let { stats -> + GradeCellViewState.GradeStats( + score = submission.score, + outOf = assignment.pointsPossible, + min = stats.min, + max = stats.max, + mean = stats.mean, + minText = resources.getString( + R.string.scoreStatisticsLow, + NumberHelper.formatDecimal(stats.min, 1, true) + ), + maxText = resources.getString( + R.string.scoreStatisticsHigh, + NumberHelper.formatDecimal(stats.max, 1, true) + ), + meanText = resources.getString( + R.string.scoreStatisticsMean, + NumberHelper.formatDecimal(stats.mean, 1, true) ) - } - - GradeCellViewData( - courseColor = courseColor, - state = State.GRADED, - chartPercent = chartPercent, - showPointsLabel = true, - score = score, - grade = grade, - gradeCellContentDescription = gradeCellContentDescription, - outOf = outOfText, - latePenalty = latePenalty, - finalGrade = finalGrade, - stats = stats ) } + + GradeCellViewData( + courseColor = courseColor, + state = State.GRADED, + chartPercent = chartPercent, + showPointsLabel = true, + score = score, + grade = grade, + gradeCellContentDescription = gradeCellContentDescription, + outOf = outOfText, + latePenalty = latePenalty, + finalGrade = finalGrade, + stats = stats + ) } } diff --git a/apps/student/src/main/java/com/instructure/student/holders/AssignmentViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/AssignmentViewHolder.kt index cd1d000543..71355343c2 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/AssignmentViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/AssignmentViewHolder.kt @@ -35,7 +35,8 @@ class AssignmentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { context: Context, assignment: Assignment, courseColor: Int, - adapterToFragmentCallback: AdapterToFragmentCallback + adapterToFragmentCallback: AdapterToFragmentCallback, + restrictQuantitativeData: Boolean ) = with(ViewholderCardGenericBinding.bind(itemView)) { title.text = assignment.name @@ -47,12 +48,13 @@ class AssignmentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val submission = assignment.submission // Posted At now determines if an assignment is muted, even for old gradebook - if (submission?.postedAt == null) { + val hideGrade = restrictQuantitativeData && assignment.isGradingTypeQuantitative && submission?.excused != true + if (submission?.postedAt == null || hideGrade) { // Mute that score points.visibility = View.GONE } else { points.visibility = View.VISIBLE - BinderUtils.setupGradeText(context, points, assignment, submission, courseColor) + BinderUtils.setupGradeText(context, points, assignment, submission, courseColor, restrictQuantitativeData) } val drawable = BinderUtils.getAssignmentIcon(assignment) diff --git a/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt b/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt index f25376f836..005c4d4a7b 100644 --- a/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt +++ b/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt @@ -130,7 +130,8 @@ object BinderUtils { return when (grade) { "complete" -> return DisplayGrade(context.getString(R.string.gradeComplete)) "incomplete" -> return DisplayGrade(context.getString(R.string.gradeIncomplete)) - else -> DisplayGrade(grade, gradeContentDescription) + // Other remaining case is where the grade is displayed as a percentage + else -> if (restrictQuantitativeData) DisplayGrade() else DisplayGrade(grade, gradeContentDescription) } } @@ -139,10 +140,11 @@ object BinderUtils { textView: TextView, assignment: Assignment, submission: Submission, - color: Int + color: Int, + restrictQuantitativeData: Boolean ) { val hasGrade = submission.grade.isValid() - val (grade, contentDescription) = getGrade(assignment, submission, context) + val (grade, contentDescription) = getGrade(assignment, submission, context, restrictQuantitativeData) if (hasGrade) { textView.text = grade textView.contentDescription = contentDescription diff --git a/apps/student/src/main/res/layout/fragment_assignment_details.xml b/apps/student/src/main/res/layout/fragment_assignment_details.xml index 867fb72753..96b9261fdf 100644 --- a/apps/student/src/main/res/layout/fragment_assignment_details.xml +++ b/apps/student/src/main/res/layout/fragment_assignment_details.xml @@ -74,6 +74,7 @@ android:text="@{viewModel.data.points}" android:textColor="@color/textDark" android:textSize="16sp" + app:visible="@{!viewModel.data.points.isEmpty()}" app:layout_constraintBottom_toBottomOf="@id/submissionStatusIcon" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/submissionStatusIcon" @@ -90,6 +91,7 @@ app:imageRes="@{viewModel.data.submissionStatusIcon}" app:layout_constraintStart_toEndOf="@id/points" app:layout_constraintTop_toBottomOf="@id/assignmentName" + app:layout_goneMarginStart="16dp" app:tint="@{viewModel.data.submissionStatusTint}" tools:src="@drawable/ic_complete_solid" tools:tint="@color/textSuccess" /> diff --git a/apps/student/src/main/res/layout/viewholder_card_generic.xml b/apps/student/src/main/res/layout/viewholder_card_generic.xml index c70ed45e18..adbe7506d5 100644 --- a/apps/student/src/main/res/layout/viewholder_card_generic.xml +++ b/apps/student/src/main/res/layout/viewholder_card_generic.xml @@ -42,6 +42,7 @@ tools:text="An assignment description with some length so we can ensure it looks amazing on every device!" /> diff --git a/apps/student/src/test/java/com/instructure/student/features/assignmentdetail/AssignmentDetailsViewModelTest.kt b/apps/student/src/test/java/com/instructure/student/features/assignmentdetail/AssignmentDetailsViewModelTest.kt index 2e483329bb..d840f2c106 100644 --- a/apps/student/src/test/java/com/instructure/student/features/assignmentdetail/AssignmentDetailsViewModelTest.kt +++ b/apps/student/src/test/java/com/instructure/student/features/assignmentdetail/AssignmentDetailsViewModelTest.kt @@ -286,7 +286,8 @@ class AssignmentDetailsViewModelTest { resources, colorKeeper.getOrGenerateColor(Course()), Assignment(), - Submission() + Submission(), + false ) every { courseManager.getCourseWithGradeAsync(any(), any()) } returns mockk { @@ -435,7 +436,8 @@ class AssignmentDetailsViewModelTest { resources, colorKeeper.getOrGenerateColor(Course()), assignment, - firstSubmission + firstSubmission, + false ) every { courseManager.getCourseWithGradeAsync(any(), any()) } returns mockk { @@ -751,4 +753,39 @@ class AssignmentDetailsViewModelTest { Assert.assertEquals(expected, viewModel.data.value?.attempts?.last()?.data?.submission) } + + @Test + fun `Create viewData with points when quantitative data is not restricted`() { + every { courseManager.getCourseWithGradeAsync(any(), any()) } returns mockk { + coEvery { await() } returns DataResult.Success(Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student)))) + } + + every { assignmentManager.getAssignmentWithHistoryAsync(any(), any(), any()) } returns mockk { + coEvery { await() } returns DataResult.Success(Assignment(submission = Submission(), pointsPossible = 20.0)) + } + + every { resources.getQuantityString(any(), any(), any()) } returns "20 pts" + + val viewModel = getViewModel() + + Assert.assertEquals("20 pts", viewModel.data.value?.points) + } + + @Test + fun `Create viewData without points when quantitative data is restricted`() { + val courseSettings = CourseSettings(restrictQuantitativeData = true) + every { courseManager.getCourseWithGradeAsync(any(), any()) } returns mockk { + coEvery { await() } returns DataResult.Success(Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student)), settings = courseSettings)) + } + + every { assignmentManager.getAssignmentWithHistoryAsync(any(), any(), any()) } returns mockk { + coEvery { await() } returns DataResult.Success(Assignment(submission = Submission(), pointsPossible = 20.0)) + } + + every { resources.getQuantityString(any(), any(), any()) } returns "20 pts" + + val viewModel = getViewModel() + + Assert.assertEquals("", viewModel.data.value?.points) + } } diff --git a/apps/student/src/test/java/com/instructure/student/features/assignmentdetail/gradecellview/GradeCellViewDataTest.kt b/apps/student/src/test/java/com/instructure/student/features/assignmentdetail/gradecellview/GradeCellViewDataTest.kt index 1e8291187b..7a96e68346 100644 --- a/apps/student/src/test/java/com/instructure/student/features/assignmentdetail/gradecellview/GradeCellViewDataTest.kt +++ b/apps/student/src/test/java/com/instructure/student/features/assignmentdetail/gradecellview/GradeCellViewDataTest.kt @@ -21,12 +21,14 @@ import android.content.res.Resources import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Submission +import com.instructure.pandautils.R import com.instructure.pandautils.utils.ColorKeeper import com.instructure.student.features.assignmentdetails.gradecellview.GradeCellViewData +import io.mockk.every import io.mockk.mockk import org.junit.Assert import org.junit.Test -import java.util.* +import java.util.Date class GradeCellViewDataTest { @@ -68,4 +70,91 @@ class GradeCellViewDataTest { Assert.assertEquals(GradeCellViewData.State.GRADED, gradeCell.state) } + + @Test + fun `Create empty grade cell when assignment is quantitative and quantitative data is restricted`() { + val gradeCell = GradeCellViewData.fromSubmission( + resources, + colorKeeper.getOrGenerateColor(Course()), + Assignment(gradingType = Assignment.POINTS_TYPE), + Submission(submittedAt = Date(), grade = "10", score = 10.0), + restrictQuantitativeData = true + ) + + Assert.assertEquals(GradeCellViewData.State.EMPTY, gradeCell.state) + } + + @Test + fun `Create excused grade cell without points when assignment is quantitative and quantitative data is restricted`() { + every { resources.getString(R.string.excused) } returns "EX" + every { resources.getString(R.string.outOfPointsAbbreviatedFormatted, any()) } returns "out of 10" + + val gradeCell = GradeCellViewData.fromSubmission( + resources, + colorKeeper.getOrGenerateColor(Course()), + Assignment(gradingType = Assignment.POINTS_TYPE), + Submission(submittedAt = Date(), grade = "10", score = 10.0, excused = true), + restrictQuantitativeData = true + ) + + Assert.assertEquals(GradeCellViewData.State.GRADED, gradeCell.state) + Assert.assertEquals("EX", gradeCell.grade) + Assert.assertEquals("", gradeCell.outOf) + } + + @Test + fun `Create excused grade cell with points when assignment is quantitative and quantitative data is not restricted`() { + every { resources.getString(R.string.excused) } returns "EX" + every { resources.getString(R.string.outOfPointsAbbreviatedFormatted, any()) } returns "out of 10" + + val gradeCell = GradeCellViewData.fromSubmission( + resources, + colorKeeper.getOrGenerateColor(Course()), + Assignment(gradingType = Assignment.POINTS_TYPE), + Submission(submittedAt = Date(), grade = "10", score = 10.0, excused = true), + restrictQuantitativeData = false + ) + + Assert.assertEquals(GradeCellViewData.State.GRADED, gradeCell.state) + Assert.assertEquals("EX", gradeCell.grade) + Assert.assertEquals("out of 10", gradeCell.outOf) + } + + @Test + fun `Create letter grade cell with points when quantitative data is not restricted`() { + every { resources.getString(R.string.outOfPointsAbbreviatedFormatted, any()) } returns "out of 10" + + val gradeCell = GradeCellViewData.fromSubmission( + resources, + colorKeeper.getOrGenerateColor(Course()), + Assignment(gradingType = Assignment.LETTER_GRADE_TYPE, pointsPossible = 10.0), + Submission(submittedAt = Date(), grade = "A", score = 10.0, enteredScore = 10.0), + restrictQuantitativeData = false + ) + + Assert.assertEquals(GradeCellViewData.State.GRADED, gradeCell.state) + Assert.assertEquals("A", gradeCell.grade) + Assert.assertEquals("10", gradeCell.score) + Assert.assertEquals(1.0f, gradeCell.chartPercent) + Assert.assertEquals("out of 10", gradeCell.outOf) + } + + @Test + fun `Create letter grade cell without points when quantitative data is restricted`() { + every { resources.getString(R.string.outOfPointsAbbreviatedFormatted, any()) } returns "out of 10" + + val gradeCell = GradeCellViewData.fromSubmission( + resources, + colorKeeper.getOrGenerateColor(Course()), + Assignment(gradingType = Assignment.LETTER_GRADE_TYPE, pointsPossible = 10.0), + Submission(submittedAt = Date(), grade = "A", score = 10.0, enteredScore = 10.0), + restrictQuantitativeData = true + ) + + Assert.assertEquals(GradeCellViewData.State.GRADED, gradeCell.state) + Assert.assertEquals("A", gradeCell.grade) + Assert.assertEquals("", gradeCell.score) + Assert.assertEquals(1.0f, gradeCell.chartPercent) + Assert.assertEquals("", gradeCell.outOf) + } } diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt index e82206a41b..63145824c9 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt @@ -970,7 +970,8 @@ fun MockCanvas.addSubmissionForAssignment( grade = grade, score = score ?: 0.0, postedAt = Date(), - excused = excused + excused = excused, + enteredScore = score ?: 0.0, ) // Get the submission list for the assignment, creating it if necessary @@ -1001,7 +1002,8 @@ fun MockCanvas.addSubmissionForAssignment( grade = grade, score = score ?: 0.0, postedAt = Date(), - excused = excused + excused = excused, + enteredScore = score ?: 0.0, ) submissionList.add(userRootSubmission) } diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index c169672ff2..3ac5a33235 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -1117,6 +1117,7 @@ %s %s %s %s, %s You can open Submission details from here + Grade: %s %s Minute From b0cefea22dc28b0f00fb3f743e8159c01917d4f8 Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Thu, 10 Aug 2023 09:19:25 +0200 Subject: [PATCH 26/61] [MBL-16927][Student] Notifications LGO refs: MBL-16927 affects: Student release note: none * Notifications LGO * Added context for restricted and excused items --- .../NotificationInteractionTest.kt | 276 ++++++++++++++++++ .../PushNotificationInteractionTest.kt | 88 ------ .../student/ui/pages/NotificationPage.kt | 22 +- .../student/holders/NotificationViewHolder.kt | 10 +- .../canvas/espresso/mockCanvas/MockCanvas.kt | 53 ++-- .../canvasapi2/models/StreamItem.kt | 6 +- libs/pandares/src/main/res/values/strings.xml | 1 + 7 files changed, 331 insertions(+), 125 deletions(-) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NotificationInteractionTest.kt delete mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PushNotificationInteractionTest.kt diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NotificationInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NotificationInteractionTest.kt new file mode 100644 index 0000000000..67523c82c9 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NotificationInteractionTest.kt @@ -0,0 +1,276 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.student.ui.interaction + +import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CourseSettings +import com.instructure.dataseeding.util.ago +import com.instructure.dataseeding.util.days +import com.instructure.dataseeding.util.iso8601 +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test +import java.util.* + +@HiltAndroidTest +class NotificationInteractionTest : StudentTest() { + override fun displaysPageObjects() = Unit // Not used for interaction tests + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testClick_itWorks() { + // Test that push notifications work when you click on them + val data = goToNotifications() + val assignment = data.assignments.values.first() + + notificationPage.assertNotificationDisplayed(assignment.name!!) + notificationPage.clickNotification(assignment.name!!) + + assignmentDetailsPage.assertAssignmentDetails(assignment) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfNotRestricted_points() { + val grade = "10.0" + val data = goToNotifications( + restrictQuantitativeData = false, + gradingType = Assignment.GradingType.POINTS, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfNotRestricted_percent() { + val grade = "10%" + val data = goToNotifications( + restrictQuantitativeData = false, + gradingType = Assignment.GradingType.PERCENT, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfNotRestricted_letter() { + val grade = "A" + val data = goToNotifications( + restrictQuantitativeData = false, + gradingType = Assignment.GradingType.LETTER_GRADE, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfNotRestricted_gpa() { + val grade = "GPA" + val data = goToNotifications( + restrictQuantitativeData = false, + gradingType = Assignment.GradingType.GPA_SCALE, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfNotRestricted_passFail() { + val grade = "complete" + val data = goToNotifications( + restrictQuantitativeData = false, + gradingType = Assignment.GradingType.PASS_FAIL, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeUpdatedIfRestricted_points() { + val grade = "10.0" + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.POINTS, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertGradeUpdated(assignment.name!!) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeUpdatedIfRestricted_percent() { + val grade = "10%" + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.PERCENT, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertGradeUpdated(assignment.name!!) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfRestricted_letter() { + val grade = "A" + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.LETTER_GRADE, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfRestricted_gpa() { + val grade = "GPA" + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.GPA_SCALE, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfRestricted_passFail() { + val grade = "complete" + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.PASS_FAIL, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showExcused() { + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.POINTS, + excused = true + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertExcused(assignment.name!!) + } + + private fun goToNotifications( + numSubmissions: Int = 1, + restrictQuantitativeData: Boolean = false, + gradingType: Assignment.GradingType = Assignment.GradingType.POINTS, + score: Double = -1.0, + grade: String? = null, + excused: Boolean = false + ): MockCanvas { + val data = MockCanvas.init(courseCount = 1, favoriteCourseCount = 1, studentCount = 1, teacherCount = 1) + + val course = data.courses.values.first() + val student = data.students.first() + + data.courses[course.id] = course.copy(settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData)) + + repeat(numSubmissions) { + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + gradingType = Assignment.gradingTypeToAPIString(gradingType).orEmpty() + ) + + val submission = data.addSubmissionForAssignment( + assignmentId = assignment.id, + userId = student.id, + type = Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString, + body = "Some words + ${UUID.randomUUID()}" + ) + + data.addSubmissionStreamItem( + user = student, + course = course, + assignment = assignment, + submission = submission, + submittedAt = 1.days.ago.iso8601, + type = "submission", + score = score, + grade = grade, + excused = excused + ) + } + + val token = data.tokenFor(student)!! + tokenLogin(data.domain, token, student) + + dashboardPage.waitForRender() + dashboardPage.clickNotificationsTab() + + return data + } +} diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PushNotificationInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PushNotificationInteractionTest.kt deleted file mode 100644 index 3a68401947..0000000000 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PushNotificationInteractionTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (C) 2019 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.student.ui.interaction - -import com.instructure.canvas.espresso.mockCanvas.* -import com.instructure.canvasapi2.models.Assignment -import com.instructure.dataseeding.util.ago -import com.instructure.dataseeding.util.days -import com.instructure.dataseeding.util.iso8601 -import com.instructure.panda_annotations.FeatureCategory -import com.instructure.panda_annotations.Priority -import com.instructure.panda_annotations.TestCategory -import com.instructure.panda_annotations.TestMetaData -import com.instructure.student.ui.utils.StudentTest -import com.instructure.student.ui.utils.tokenLogin -import dagger.hilt.android.testing.HiltAndroidTest -import org.junit.Test -import java.util.* - -@HiltAndroidTest -class PushNotificationInteractionTest : StudentTest() { - override fun displaysPageObjects() = Unit // Not used for interaction tests - - @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.NONE, TestCategory.INTERACTION, false) - fun testClick_itWorks() { - // Test that push notifications work when you click on them - val data = goToNotifications() - val assignment = data.assignments.values.first() - - notificationPage.assertNotificationDisplayed(assignment.name!!) - notificationPage.clickNotification(assignment.name!!) - - assignmentDetailsPage.assertAssignmentDetails(assignment) - } - - private fun goToNotifications(numSubmissions: Int = 1) : MockCanvas { - val data = MockCanvas.init(courseCount = 1, favoriteCourseCount = 1, studentCount = 1, teacherCount = 1) - - val course = data.courses.values.first() - val student = data.students.first() - - repeat(numSubmissions) { - val assignment = data.addAssignment( - courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY - ) - - val submission = data.addSubmissionForAssignment( - assignmentId = assignment.id, - userId = student.id, - type = Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString, - body = "Some words + ${UUID.randomUUID()}" - ) - - val streamItem = data.addSubmissionStreamItem( - user = student, - course = course, - assignment = assignment, - submission = submission, - submittedAt = 1.days.ago.iso8601, - type = "submission" - ) - } - - val token = data.tokenFor(student)!! - tokenLogin(data.domain, token, student) - - dashboardPage.waitForRender() - dashboardPage.clickNotificationsTab() - - return data - } - -} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt index ff347c6f4e..f189dec280 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt @@ -22,16 +22,12 @@ import androidx.test.espresso.matcher.ViewMatchers.hasSibling import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.refresh import com.instructure.canvas.espresso.scrollRecyclerView -import com.instructure.espresso.RecyclerViewItemCountGreaterThanAssertion -import com.instructure.espresso.assertDisplayed -import com.instructure.espresso.click +import com.instructure.espresso.* import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.plus import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withId import com.instructure.espresso.page.withText -import com.instructure.espresso.scrollTo -import com.instructure.espresso.waitForCheck import com.instructure.student.R import org.hamcrest.CoreMatchers.allOf import org.hamcrest.Matchers @@ -42,7 +38,6 @@ class NotificationPage : BasePage() { val matcher = withText(title) scrollRecyclerView(R.id.listView, matcher) onView(matcher).assertDisplayed() - } fun assertHasGrade(title: String, grade: String) { @@ -50,6 +45,16 @@ class NotificationPage : BasePage() { onView(matcher).scrollTo().assertDisplayed() } + fun assertGradeUpdated(title: String) { + val matcher = allOf(containsTextCaseInsensitive(title.dropLast(1)) + hasSibling(withId(R.id.description) + withText("Grade updated"))) + onView(matcher).scrollTo().assertDisplayed() + } + + fun assertExcused(title: String) { + val matcher = allOf(containsTextCaseInsensitive(title.dropLast(1)) + hasSibling(withId(R.id.description) + withText("Excused"))) + onView(matcher).scrollTo().assertDisplayed() + } + fun clickNotification(title: String) { val matcher = withText(title) scrollRecyclerView(R.id.listView, matcher) @@ -59,15 +64,14 @@ class NotificationPage : BasePage() { fun assertNotificationWithPoll(title: String, times: Int, pollIntervalSeconds: Long) { var iteration = 0 while (iteration < times) { - Thread.sleep(pollIntervalSeconds*1000) + Thread.sleep(pollIntervalSeconds * 1000) try { val words = title.split(" ") onView(containsTextCaseInsensitive(words[0] + " " + words[1] + " " + words[2])).assertDisplayed() - } catch(e: NoMatchingViewException) { + } catch (e: NoMatchingViewException) { iteration++ refresh() } - } } diff --git a/apps/student/src/main/java/com/instructure/student/holders/NotificationViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/NotificationViewHolder.kt index c0b3b0ae9f..22f5f09bc2 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/NotificationViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/NotificationViewHolder.kt @@ -24,6 +24,7 @@ import android.view.View import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.StreamItem import com.instructure.pandautils.utils.* import com.instructure.student.R @@ -98,8 +99,10 @@ class NotificationViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) drawableResId = R.drawable.ic_assignment icon.contentDescription = context.getString(R.string.assignmentIcon) + val restrictQuantitativeData = (item.canvasContext as? Course)?.settings?.restrictQuantitativeData.orDefault() + && item.assignment?.isGradingTypeQuantitative.orDefault() // Need to prepend "Grade" in the message if there is a valid score - if (item.score != -1.0) { + if (item.score != -1.0 && !restrictQuantitativeData) { // If the submission has a grade (like a letter or percentage) display it if (item.grade != null && item.grade != "" @@ -109,6 +112,11 @@ class NotificationViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) } else { description.text = context.resources.getString(R.string.grade) + description.text } + } else if (item.excused) { + description.text = context.resources.getString(R.string.gradeExcused) + description.setVisible() + } else { + description.text = context.resources.getString(R.string.gradeUpdated) } } StreamItem.Type.CONVERSATION -> { diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt index 63145824c9..6e3a71bdb0 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt @@ -1974,37 +1974,42 @@ private val canvaDocInk = CanvaDocInkList( * Consider doing this automatically whenever a submission is processed? */ fun MockCanvas.addSubmissionStreamItem( - user: User, - course: Course, - assignment: Assignment, - submission: Submission, - submittedAt: String? = null, - message: String = Faker.instance().lorem().sentence(), - type : String = "submission" -) : StreamItem { + user: User, + course: Course, + assignment: Assignment, + submission: Submission, + submittedAt: String? = null, + message: String = Faker.instance().lorem().sentence(), + type: String = "submission", + score: Double = -1.0, + grade: String? = null, + excused: Boolean = false +): StreamItem { // Create the StreamItem val item = StreamItem( - id = newItemId(), - course_id = course.id, - assignment_id = assignment.id, - title = assignment.name, - message = message, - assignment = assignment, - type = type, - submittedAt = submittedAt, - userId = user.id, - user = user, - updatedAt = submittedAt ?: "", - htmlUrl = "https://$domain/courses/${course.id}/assignments/${assignment.id}/submissions/${submission.id}", - context_type = CanvasContext.Type.USER.apiString - //canvasContext = user // This seems to break the notifications page so that it does not load - + id = newItemId(), + course_id = course.id, + assignment_id = assignment.id, + title = assignment.name, + message = message, + assignment = assignment, + type = type, + submittedAt = submittedAt, + userId = user.id, + user = user, + updatedAt = submittedAt ?: "", + htmlUrl = "https://$domain/courses/${course.id}/assignments/${assignment.id}/submissions/${submission.id}", + context_type = CanvasContext.Type.USER.apiString, + score = score, + grade = grade, + excused = excused + //canvasContext = user // This seems to break the notifications page so that it does not load ) // Record the StreamItem var list = streamItems[user.id] if (list == null) { - list = mutableListOf() + list = mutableListOf() streamItems[user.id] = list } list.add(item) diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt index 7a826b4c4a..074e09cfba 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt @@ -24,8 +24,7 @@ import com.instructure.canvasapi2.utils.APIHelper import com.instructure.canvasapi2.utils.toDate import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -import java.util.ArrayList -import java.util.Locale +import java.util.* @Parcelize data class StreamItem( @@ -93,7 +92,8 @@ data class StreamItem( val assignment: Assignment? = null, @SerializedName("user_id") val userId: Long = -1, - val user: User = User() + val user: User = User(), + val excused: Boolean = false ) : CanvasModel() { // We want opposite of natural sorting order of date since we want the newest one to come first override val comparisonDate get() = updatedDate diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 3ac5a33235..c1648b020a 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -431,6 +431,7 @@ Deleted + Grade updated Loading Canvas Content… From 1ab10f8fca8c21f0b60119a205662911ab91bf29 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Fri, 11 Aug 2023 07:58:07 +0200 Subject: [PATCH 27/61] [MBL-16925][Student] Widgets LGO (#2098) refs: MBL-16925 affects: Student release note: none * Grades widget * Notifications widget. * Fixed group discussions. --- .../fragment/DiscussionDetailsFragment.kt | 10 +++++- .../student/widget/GradesViewWidgetService.kt | 20 +++++++++-- .../widget/NotificationViewWidgetService.kt | 6 ++-- .../canvasapi2/models/StreamItem.kt | 34 ++++++++++++------- 4 files changed, 52 insertions(+), 18 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt index 104237e0d9..f3b43fbb6c 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt @@ -553,7 +553,15 @@ class DiscussionDetailsFragment : ParentFragment(), Bookmarkable { // Do we have a discussion topic header? if not fetch it, or if forceRefresh is true force a fetch - courseSettings = CourseManager.getCourseSettingsAsync(canvasContext.id, forceRefresh).await().dataOrNull + val courseId = when (canvasContext) { + is Course -> canvasContext.id + is Group -> (canvasContext as Group).courseId + else -> null + } + + if (courseId != null) { + courseSettings = CourseManager.getCourseSettingsAsync(courseId, forceRefresh).await().dataOrNull + } if (forceRefresh) { val discussionTopicHeaderId = if (discussionTopicHeaderId == 0L && discussionTopicHeader.id != 0L) discussionTopicHeader.id else discussionTopicHeaderId diff --git a/apps/student/src/main/java/com/instructure/student/widget/GradesViewWidgetService.kt b/apps/student/src/main/java/com/instructure/student/widget/GradesViewWidgetService.kt index bf3f8e30b8..052d93b02a 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/GradesViewWidgetService.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/GradesViewWidgetService.kt @@ -29,8 +29,8 @@ import com.instructure.student.R import com.instructure.student.activity.InterwebsToApplication import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.CourseGrade import com.instructure.canvasapi2.utils.* -import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Const import java.io.Serializable @@ -98,7 +98,8 @@ class GradesViewWidgetService : BaseRemoteViewsService(), Serializable { if (courseGrade.noCurrentGrade) { row.setTextViewText(R.id.courseGrade, applicationContext.getString(R.string.noGradeText)) } else { - row.setTextViewText(R.id.courseGrade, NumberHelper.doubleToPercentage(courseGrade.currentScore, 2)) + val grade = formatGrade(streamItem, courseGrade) + row.setTextViewText(R.id.courseGrade, grade) } } } @@ -106,7 +107,20 @@ class GradesViewWidgetService : BaseRemoteViewsService(), Serializable { row.setInt(R.id.courseIndicator, "setColorFilter", getCanvasContextTextColor(appWidgetId, streamItem)) } - + + private fun formatGrade(course: Course, courseGrade: CourseGrade): String { + return if (course.settings?.restrictQuantitativeData == true) { + if (courseGrade.currentGrade.isNullOrEmpty()) { + applicationContext.getString(R.string.noGradeText) + } else { + courseGrade.currentGrade.orEmpty() + } + } else { + val scoreString = NumberHelper.doubleToPercentage(courseGrade.currentScore, 2) + "${if (courseGrade.hasCurrentGradeString()) courseGrade.currentGrade else ""} $scoreString" + } + } + override fun clearViewData(row: RemoteViews) { row.setTextViewText(R.id.courseGrade, "") row.setTextViewText(R.id.courseTerm, "") diff --git a/apps/student/src/main/java/com/instructure/student/widget/NotificationViewWidgetService.kt b/apps/student/src/main/java/com/instructure/student/widget/NotificationViewWidgetService.kt index b0731f4730..566a268e98 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/NotificationViewWidgetService.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/NotificationViewWidgetService.kt @@ -31,6 +31,7 @@ import com.instructure.canvasapi2.managers.GroupManager import com.instructure.canvasapi2.managers.InboxManager import com.instructure.canvasapi2.managers.StreamManager import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.StreamItem import com.instructure.canvasapi2.utils.* import com.instructure.pandautils.utils.ColorKeeper @@ -86,8 +87,9 @@ class NotificationViewWidgetService : BaseRemoteViewsService(), Serializable { } if (!BaseRemoteViewsService.shouldHideDetails(appWidgetId)) { - if (streamItem.getMessage(ContextKeeper.appContext) != null) { - row.setTextViewText(R.id.message, StringUtilities.simplifyHTML(Html.fromHtml(streamItem.getMessage(ContextKeeper.appContext), Html.FROM_HTML_MODE_LEGACY))) + val restrictQuantitativeData = (streamItem.canvasContext as? Course)?.settings?.restrictQuantitativeData ?: false + if (streamItem.getMessage(ContextKeeper.appContext, restrictQuantitativeData) != null) { + row.setTextViewText(R.id.message, StringUtilities.simplifyHTML(Html.fromHtml(streamItem.getMessage(ContextKeeper.appContext, restrictQuantitativeData), Html.FROM_HTML_MODE_LEGACY))) row.setTextColor(R.id.message, BaseRemoteViewsService.getWidgetSecondaryTextColor(appWidgetId, applicationContext)) } else { row.setTextViewText(R.id.message, "") diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt index 074e09cfba..7e50e7e874 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt @@ -178,9 +178,9 @@ data class StreamItem( return title } - fun getMessage(context: Context): String? { + fun getMessage(context: Context, restrictQuantitativeData: Boolean = false): String? { if (message == null) { - message = createMessage(context) + message = createMessage(context, restrictQuantitativeData) } return message } @@ -214,7 +214,7 @@ data class StreamItem( } } - private fun createMessage(context: Context): String? { + private fun createMessage(context: Context, restrictQuantitativeData: Boolean = false): String? { when (getStreamItemType()) { StreamItem.Type.CONVERSATION -> { if (conversation == null) { @@ -226,18 +226,20 @@ data class StreamItem( } StreamItem.Type.SUBMISSION -> { // Get comments from assignment - var comment: String? = null + var comment: String = "" if (submissionComments.isNotEmpty()) { - comment = submissionComments[submissionComments.size - 1].comment + val lastComment = submissionComments.last().comment + if (lastComment != null && lastComment != "null") comment = lastComment } - // Set it to the last comment if it's not null - if (comment != null && comment != "null" && score != -1.0) { - return ":$score $comment" - } else if ((comment == null || comment == "null") && score != -1.0) { - return ":$score" - } else if (comment != null && comment != "null" && score == -1.0) { - return comment + + val displayedGrade = when { + excused -> context.getString(R.string.gradeExcused) + restrictQuantitativeData -> getGradeWhenQuantitativeDataRestricted(context) + score != -1.0 -> score.toString().orEmpty() + else -> "" } + + return "$displayedGrade $comment" } StreamItem.Type.DISCUSSION_TOPIC -> // If it's a discussionTopic, get the last entry for the message. @@ -253,6 +255,14 @@ data class StreamItem( } else message } + private fun getGradeWhenQuantitativeDataRestricted(context: Context): String { + return if (assignment?.isGradingTypeQuantitative == true) { + context.getString(R.string.gradeUpdated) + } else { + grade.orEmpty() + } + } + private fun parseAssignmentId(): Long { // Get the assignment from the url if (htmlUrl.isNotEmpty() && htmlUrl != "null") { From 7fdda4b3ce5195d5e4777aa0108fdbf512636ff8 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Fri, 11 Aug 2023 07:58:56 +0200 Subject: [PATCH 28/61] [MBL-16929][Student] K5 Schedule LGO #2097 refs: MBL-16929 affects: Student release note: none --- .../elementary/schedule/ScheduleViewModel.kt | 10 +- .../schedule/ScheduleViewModelTest.kt | 107 +++++++++++++++++- 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModel.kt index c1964fa976..c819bbee85 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModel.kt @@ -188,7 +188,7 @@ class ScheduleViewModel @Inject constructor( simpleDateFormat.format(it) ) }, - points = getPointsText(assignment.pointsPossible), + points = getPointsText(assignment.pointsPossible, assignment.courseId), type = if (assignment.discussionTopicHeader != null) PlannerItemType.DISCUSSION else PlannerItemType.ASSIGNMENT, courseName = coursesMap[assignment.courseId]?.name, courseColor = color, @@ -342,7 +342,7 @@ class ScheduleViewModel @Inject constructor( SchedulePlannerItemData( plannerItem.plannable.title, getTypeForPlannerItem(plannerItem), - getPointsText(plannerItem.plannable.pointsPossible), + getPointsText(plannerItem.plannable.pointsPossible, plannerItem.courseId ?: 0), getDueText(plannerItem), isPlannableOpenable(plannerItem), createContentDescription(plannerItem), @@ -513,8 +513,12 @@ class ScheduleViewModel @Inject constructor( } } - private fun getPointsText(points: Double?): String? { + private fun getPointsText(points: Double?, courseId: Long): String? { if (points == null) return null + + val course = coursesMap[courseId] + if (course?.settings?.restrictQuantitativeData == true) return null + val numberFormatter = DecimalFormat("##.##") return resources.getQuantityString(R.plurals.schedule_points, points.toInt(), numberFormatter.format(points)) } diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt index 72b89e395e..6d97801bea 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt @@ -201,7 +201,8 @@ class ScheduleViewModelTest { 1, courseId = 1, createSubmission(id = 1, grade = null, late = false, excused = false), - name = "Assignment 1" + name = "Assignment 1", + pointsPossible = 20.0 ), createAssignment( 2, @@ -232,12 +233,55 @@ class ScheduleViewModelTest { val firstMissingItem = missingItemHeader.items[0] as ScheduleMissingItemViewModel assertEquals("Assignment 1", firstMissingItem.data.title) assertEquals("Course 1", firstMissingItem.data.courseName) + assertEquals("20 pts", firstMissingItem.data.points) val secondMissingItem = missingItemHeader.items[1] as ScheduleMissingItemViewModel assertEquals("Assignment 2", secondMissingItem.data.title) assertEquals("Course 2", secondMissingItem.data.courseName) } + @Test + fun `Missing item points are not displayed if quantitative data is restricted`() { + val courses = listOf(Course(id = 1, name = "Course 1", settings = CourseSettings(restrictQuantitativeData = true)),) + + every { courseManager.getCoursesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(courses) + } + + val missingItems = listOf( + createAssignment( + 1, + courseId = 1, + createSubmission(id = 1, grade = null, late = false, excused = false), + name = "Assignment 1", + pointsPossible = 20.0 + ) + ) + + every { userManager.getAllMissingSubmissionsAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(missingItems) + } + + viewModel = createViewModel() + viewModel.getDataForDate(Date().toApiString()) + viewModel.data.observe(lifecycleOwner, {}) + + val items = viewModel.data.value?.itemViewModels + + val todayHeader = items?.find { it.dayText == "Today" } + assert(todayHeader is ScheduleDayGroupItemViewModel) + assertEquals("Today", todayHeader?.dayText) + + val missingItemHeader = + todayHeader?.items?.find { it is ScheduleMissingItemsGroupItemViewModel } as ScheduleMissingItemsGroupItemViewModel + assertEquals(1, missingItemHeader.items.size) + + val firstMissingItem = missingItemHeader.items.first() as ScheduleMissingItemViewModel + assertEquals("Assignment 1", firstMissingItem.data.title) + assertEquals("Course 1", firstMissingItem.data.courseName) + assertEquals(null, firstMissingItem.data.points) + } + @Test fun `Missing items are open by default`() { val course = Course(id = 1) @@ -640,7 +684,54 @@ class ScheduleViewModelTest { assignmentId = 1, PlannableType.ASSIGNMENT, SubmissionState(), - Date() + Date(), + pointsPossible = 20.0 + ) + ) + + every { plannerManager.getPlannerItemsAsync(any(), any(), any()) } returns mockk { + coEvery { await() } returns DataResult.Success(plannerItems) + } + + viewModel = createViewModel() + viewModel.getDataForDate(Date().toApiString()) + viewModel.data.observe(lifecycleOwner, {}) + + val items = viewModel.data.value?.itemViewModels + + val todayHeader = items?.find { it.dayText == "Today" } + assert(todayHeader is ScheduleDayGroupItemViewModel) + assertEquals("Today", todayHeader?.dayText) + + val courseItemViewModel = todayHeader?.items?.get(0) as ScheduleCourseItemViewModel + + assertEquals(true, courseItemViewModel.data.openable) + + assertEquals(1, courseItemViewModel.data.plannerItems.size) + val plannerItemViewModel = courseItemViewModel.data.plannerItems[0] + + assertEquals("Plannable 1", plannerItemViewModel.data.title) + assertEquals(true, plannerItemViewModel.data.openable) + assertEquals(PlannerItemType.ASSIGNMENT, plannerItemViewModel.data.type) + assertEquals("20 pts", plannerItemViewModel.data.points) + } + + @Test + fun `Assignment points are not displayed with restricted quantitative data`() { + val course = Course(id = 1, settings = CourseSettings(restrictQuantitativeData = true)) + + every { courseManager.getCoursesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(listOf(course)) + } + + val plannerItems = listOf( + createPlannerItem( + courseId = course.id, + assignmentId = 1, + PlannableType.ASSIGNMENT, + SubmissionState(), + Date(), + pointsPossible = 20.0 ) ) @@ -818,7 +909,8 @@ class ScheduleViewModelTest { date: Date, plannerOverride: PlannerOverride? = null, newActivity: Boolean = false, - todoDate: String? = null + todoDate: String? = null, + pointsPossible: Double? = null ): PlannerItem { val plannable = Plannable( id = assignmentId, @@ -826,7 +918,7 @@ class ScheduleViewModelTest { courseId, null, null, - null, + pointsPossible, date, assignmentId, todoDate @@ -866,14 +958,16 @@ class ScheduleViewModelTest { courseId: Long, submission: Submission? = null, discussionTopicHeader: DiscussionTopicHeader? = null, - name: String? = null + name: String? = null, + pointsPossible: Double? = null ): Assignment { return Assignment( id = id, submission = submission, discussionTopicHeader = discussionTopicHeader, courseId = courseId, - name = name + name = name, + pointsPossible = pointsPossible ?: 0.0 ) } @@ -912,6 +1006,7 @@ class ScheduleViewModelTest { every { resources.getString(R.string.schedule_todo_title) } returns "To Do" every { resources.getQuantityString(R.plurals.schedule_tag_replies, 2, 2) } returns "2 Replies" every { resources.getQuantityString(R.plurals.schedule_tag_replies, 1, 1) } returns "1 Reply" + every { resources.getQuantityString(R.plurals.schedule_points, 20, "20") } returns "20 pts" } private fun createViewModel(): ScheduleViewModel { From 332a03953dd4ef3d38a4c33b98c548ba3aba6130 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Fri, 11 Aug 2023 08:00:32 +0200 Subject: [PATCH 29/61] [MBL-16931][Student] K5 Grades LGO (#2096) refs: MBL-16931 affects: Student release note: none * Elementary grades LGO. * Added computedCurrentLetterGrade changes for the Parent app. * Unit tests. * Integration tests. --- .../lib/models/course_grade.dart | 2 +- .../flutter_parent/lib/models/enrollment.dart | 4 + .../lib/models/enrollment.g.dart | 390 +++++++++--------- .../ElementaryGradesInteractionTest.kt | 44 ++ .../student/ui/pages/GradesPage.kt | 6 + .../canvas/espresso/mockCanvas/MockCanvas.kt | 17 +- .../instructure/canvasapi2/apis/CourseAPI.kt | 2 +- .../canvasapi2/models/Enrollment.kt | 4 +- .../elementary/grades/GradesViewData.kt | 3 +- .../elementary/grades/GradesViewModel.kt | 19 +- .../res/layout-sw720dp/item_grade_row.xml | 1 + .../src/main/res/layout/item_grade_row.xml | 4 +- .../elementary/grades/GradesViewModelTest.kt | 57 ++- 13 files changed, 339 insertions(+), 214 deletions(-) diff --git a/apps/flutter_parent/lib/models/course_grade.dart b/apps/flutter_parent/lib/models/course_grade.dart index 5497a97b13..438bf11ca5 100644 --- a/apps/flutter_parent/lib/models/course_grade.dart +++ b/apps/flutter_parent/lib/models/course_grade.dart @@ -83,7 +83,7 @@ class CourseGrade { // double _getFinalScore() => // _enrollment.grade?.finalScore ?? _enrollment.computedFinalScore; - String _getCurrentGrade() => _enrollment?.grades?.currentGrade ?? _enrollment?.computedCurrentGrade; + String _getCurrentGrade() => _enrollment?.grades?.currentGrade ?? _enrollment?.computedCurrentGrade ?? _enrollment?.computedCurrentLetterGrade; // String _getFinalGrade() => // _enrollment.grade?.finalGrade ?? _enrollment.computedFinalGrade; diff --git a/apps/flutter_parent/lib/models/enrollment.dart b/apps/flutter_parent/lib/models/enrollment.dart index 4d1c904b0e..ed0cfc91b7 100644 --- a/apps/flutter_parent/lib/models/enrollment.dart +++ b/apps/flutter_parent/lib/models/enrollment.dart @@ -77,6 +77,10 @@ abstract class Enrollment implements Built { @BuiltValueField(wireName: 'computed_final_grade') String get computedFinalGrade; + @nullable + @BuiltValueField(wireName: 'computed_current_letter_grade') + String get computedCurrentLetterGrade; + @BuiltValueField(wireName: 'multiple_grading_periods_enabled') bool get multipleGradingPeriodsEnabled; diff --git a/apps/flutter_parent/lib/models/enrollment.g.dart b/apps/flutter_parent/lib/models/enrollment.g.dart index 695fde2b46..00202c3e20 100644 --- a/apps/flutter_parent/lib/models/enrollment.g.dart +++ b/apps/flutter_parent/lib/models/enrollment.g.dart @@ -39,132 +39,119 @@ class _$EnrollmentSerializer implements StructuredSerializer { serializers.serialize(object.limitPrivilegesToCourseSection, specifiedType: const FullType(bool)), ]; - result.add('role'); - if (object.role == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.role, - specifiedType: const FullType(String))); - } - result.add('type'); - if (object.type == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.type, - specifiedType: const FullType(String))); - } - result.add('course_id'); - if (object.courseId == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.courseId, - specifiedType: const FullType(String))); - } - result.add('course_section_id'); - if (object.courseSectionId == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.courseSectionId, - specifiedType: const FullType(String))); - } - result.add('grades'); - if (object.grades == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.grades, - specifiedType: const FullType(Grade))); - } - result.add('computed_current_score'); - if (object.computedCurrentScore == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.computedCurrentScore, - specifiedType: const FullType(double))); - } - result.add('computed_final_score'); - if (object.computedFinalScore == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.computedFinalScore, - specifiedType: const FullType(double))); - } - result.add('computed_current_grade'); - if (object.computedCurrentGrade == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.computedCurrentGrade, - specifiedType: const FullType(String))); - } - result.add('computed_final_grade'); - if (object.computedFinalGrade == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.computedFinalGrade, - specifiedType: const FullType(String))); - } - result.add('current_period_computed_current_score'); - if (object.currentPeriodComputedCurrentScore == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentPeriodComputedCurrentScore, - specifiedType: const FullType(double))); - } - result.add('current_period_computed_final_score'); - if (object.currentPeriodComputedFinalScore == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentPeriodComputedFinalScore, - specifiedType: const FullType(double))); - } - result.add('current_period_computed_current_grade'); - if (object.currentPeriodComputedCurrentGrade == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentPeriodComputedCurrentGrade, - specifiedType: const FullType(String))); - } - result.add('current_period_computed_final_grade'); - if (object.currentPeriodComputedFinalGrade == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentPeriodComputedFinalGrade, - specifiedType: const FullType(String))); - } - result.add('current_grading_period_id'); - if (object.currentGradingPeriodId == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentGradingPeriodId, - specifiedType: const FullType(String))); - } - result.add('current_grading_period_title'); - if (object.currentGradingPeriodTitle == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentGradingPeriodTitle, - specifiedType: const FullType(String))); - } - result.add('last_activity_at'); - if (object.lastActivityAt == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.lastActivityAt, + Object value; + value = object.role; + + result + ..add('role') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.type; + + result + ..add('type') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.courseId; + + result + ..add('course_id') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.courseSectionId; + + result + ..add('course_section_id') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.grades; + + result + ..add('grades') + ..add(serializers.serialize(value, specifiedType: const FullType(Grade))); + value = object.computedCurrentScore; + + result + ..add('computed_current_score') + ..add( + serializers.serialize(value, specifiedType: const FullType(double))); + value = object.computedFinalScore; + + result + ..add('computed_final_score') + ..add( + serializers.serialize(value, specifiedType: const FullType(double))); + value = object.computedCurrentGrade; + + result + ..add('computed_current_grade') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.computedFinalGrade; + + result + ..add('computed_final_grade') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.computedCurrentLetterGrade; + + result + ..add('computed_current_letter_grade') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.currentPeriodComputedCurrentScore; + + result + ..add('current_period_computed_current_score') + ..add( + serializers.serialize(value, specifiedType: const FullType(double))); + value = object.currentPeriodComputedFinalScore; + + result + ..add('current_period_computed_final_score') + ..add( + serializers.serialize(value, specifiedType: const FullType(double))); + value = object.currentPeriodComputedCurrentGrade; + + result + ..add('current_period_computed_current_grade') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.currentPeriodComputedFinalGrade; + + result + ..add('current_period_computed_final_grade') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.currentGradingPeriodId; + + result + ..add('current_grading_period_id') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.currentGradingPeriodTitle; + + result + ..add('current_grading_period_title') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.lastActivityAt; + + result + ..add('last_activity_at') + ..add(serializers.serialize(value, specifiedType: const FullType(DateTime))); - } - result.add('observed_user'); - if (object.observedUser == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.observedUser, - specifiedType: const FullType(User))); - } - result.add('user'); - if (object.user == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.user, - specifiedType: const FullType(User))); - } + value = object.observedUser; + + result + ..add('observed_user') + ..add(serializers.serialize(value, specifiedType: const FullType(User))); + value = object.user; + + result + ..add('user') + ..add(serializers.serialize(value, specifiedType: const FullType(User))); + return result; } @@ -177,8 +164,7 @@ class _$EnrollmentSerializer implements StructuredSerializer { while (iterator.moveNext()) { final key = iterator.current as String; iterator.moveNext(); - final dynamic value = iterator.current; - if (value == null) continue; + final Object value = iterator.current; switch (key) { case 'role': result.role = serializers.deserialize(value, @@ -228,6 +214,10 @@ class _$EnrollmentSerializer implements StructuredSerializer { result.computedFinalGrade = serializers.deserialize(value, specifiedType: const FullType(String)) as String; break; + case 'computed_current_letter_grade': + result.computedCurrentLetterGrade = serializers.deserialize(value, + specifiedType: const FullType(String)) as String; + break; case 'multiple_grading_periods_enabled': result.multipleGradingPeriodsEnabled = serializers.deserialize(value, specifiedType: const FullType(bool)) as bool; @@ -317,6 +307,8 @@ class _$Enrollment extends Enrollment { @override final String computedFinalGrade; @override + final String computedCurrentLetterGrade; + @override final bool multipleGradingPeriodsEnabled; @override final bool totalsForAllGradingPeriodsOption; @@ -359,6 +351,7 @@ class _$Enrollment extends Enrollment { this.computedFinalScore, this.computedCurrentGrade, this.computedFinalGrade, + this.computedCurrentLetterGrade, this.multipleGradingPeriodsEnabled, this.totalsForAllGradingPeriodsOption, this.currentPeriodComputedCurrentScore, @@ -373,30 +366,18 @@ class _$Enrollment extends Enrollment { this.observedUser, this.user}) : super._() { - if (id == null) { - throw new BuiltValueNullFieldError('Enrollment', 'id'); - } - if (enrollmentState == null) { - throw new BuiltValueNullFieldError('Enrollment', 'enrollmentState'); - } - if (userId == null) { - throw new BuiltValueNullFieldError('Enrollment', 'userId'); - } - if (multipleGradingPeriodsEnabled == null) { - throw new BuiltValueNullFieldError( - 'Enrollment', 'multipleGradingPeriodsEnabled'); - } - if (totalsForAllGradingPeriodsOption == null) { - throw new BuiltValueNullFieldError( - 'Enrollment', 'totalsForAllGradingPeriodsOption'); - } - if (associatedUserId == null) { - throw new BuiltValueNullFieldError('Enrollment', 'associatedUserId'); - } - if (limitPrivilegesToCourseSection == null) { - throw new BuiltValueNullFieldError( - 'Enrollment', 'limitPrivilegesToCourseSection'); - } + BuiltValueNullFieldError.checkNotNull(id, 'Enrollment', 'id'); + BuiltValueNullFieldError.checkNotNull( + enrollmentState, 'Enrollment', 'enrollmentState'); + BuiltValueNullFieldError.checkNotNull(userId, 'Enrollment', 'userId'); + BuiltValueNullFieldError.checkNotNull(multipleGradingPeriodsEnabled, + 'Enrollment', 'multipleGradingPeriodsEnabled'); + BuiltValueNullFieldError.checkNotNull(totalsForAllGradingPeriodsOption, + 'Enrollment', 'totalsForAllGradingPeriodsOption'); + BuiltValueNullFieldError.checkNotNull( + associatedUserId, 'Enrollment', 'associatedUserId'); + BuiltValueNullFieldError.checkNotNull(limitPrivilegesToCourseSection, + 'Enrollment', 'limitPrivilegesToCourseSection'); } @override @@ -422,6 +403,7 @@ class _$Enrollment extends Enrollment { computedFinalScore == other.computedFinalScore && computedCurrentGrade == other.computedCurrentGrade && computedFinalGrade == other.computedFinalGrade && + computedCurrentLetterGrade == other.computedCurrentLetterGrade && multipleGradingPeriodsEnabled == other.multipleGradingPeriodsEnabled && totalsForAllGradingPeriodsOption == other.totalsForAllGradingPeriodsOption && @@ -463,13 +445,13 @@ class _$Enrollment extends Enrollment { $jc( $jc( $jc( - $jc($jc($jc($jc($jc($jc($jc(0, role.hashCode), type.hashCode), id.hashCode), courseId.hashCode), courseSectionId.hashCode), enrollmentState.hashCode), - userId.hashCode), - grades.hashCode), - computedCurrentScore.hashCode), - computedFinalScore.hashCode), - computedCurrentGrade.hashCode), - computedFinalGrade.hashCode), + $jc($jc($jc($jc($jc($jc($jc($jc(0, role.hashCode), type.hashCode), id.hashCode), courseId.hashCode), courseSectionId.hashCode), enrollmentState.hashCode), userId.hashCode), + grades.hashCode), + computedCurrentScore.hashCode), + computedFinalScore.hashCode), + computedCurrentGrade.hashCode), + computedFinalGrade.hashCode), + computedCurrentLetterGrade.hashCode), multipleGradingPeriodsEnabled.hashCode), totalsForAllGradingPeriodsOption.hashCode), currentPeriodComputedCurrentScore.hashCode), @@ -500,6 +482,7 @@ class _$Enrollment extends Enrollment { ..add('computedFinalScore', computedFinalScore) ..add('computedCurrentGrade', computedCurrentGrade) ..add('computedFinalGrade', computedFinalGrade) + ..add('computedCurrentLetterGrade', computedCurrentLetterGrade) ..add('multipleGradingPeriodsEnabled', multipleGradingPeriodsEnabled) ..add('totalsForAllGradingPeriodsOption', totalsForAllGradingPeriodsOption) @@ -580,6 +563,11 @@ class EnrollmentBuilder implements Builder { set computedFinalGrade(String computedFinalGrade) => _$this._computedFinalGrade = computedFinalGrade; + String _computedCurrentLetterGrade; + String get computedCurrentLetterGrade => _$this._computedCurrentLetterGrade; + set computedCurrentLetterGrade(String computedCurrentLetterGrade) => + _$this._computedCurrentLetterGrade = computedCurrentLetterGrade; + bool _multipleGradingPeriodsEnabled; bool get multipleGradingPeriodsEnabled => _$this._multipleGradingPeriodsEnabled; @@ -661,34 +649,34 @@ class EnrollmentBuilder implements Builder { } EnrollmentBuilder get _$this { - if (_$v != null) { - _role = _$v.role; - _type = _$v.type; - _id = _$v.id; - _courseId = _$v.courseId; - _courseSectionId = _$v.courseSectionId; - _enrollmentState = _$v.enrollmentState; - _userId = _$v.userId; - _grades = _$v.grades?.toBuilder(); - _computedCurrentScore = _$v.computedCurrentScore; - _computedFinalScore = _$v.computedFinalScore; - _computedCurrentGrade = _$v.computedCurrentGrade; - _computedFinalGrade = _$v.computedFinalGrade; - _multipleGradingPeriodsEnabled = _$v.multipleGradingPeriodsEnabled; - _totalsForAllGradingPeriodsOption = _$v.totalsForAllGradingPeriodsOption; - _currentPeriodComputedCurrentScore = - _$v.currentPeriodComputedCurrentScore; - _currentPeriodComputedFinalScore = _$v.currentPeriodComputedFinalScore; - _currentPeriodComputedCurrentGrade = - _$v.currentPeriodComputedCurrentGrade; - _currentPeriodComputedFinalGrade = _$v.currentPeriodComputedFinalGrade; - _currentGradingPeriodId = _$v.currentGradingPeriodId; - _currentGradingPeriodTitle = _$v.currentGradingPeriodTitle; - _associatedUserId = _$v.associatedUserId; - _lastActivityAt = _$v.lastActivityAt; - _limitPrivilegesToCourseSection = _$v.limitPrivilegesToCourseSection; - _observedUser = _$v.observedUser?.toBuilder(); - _user = _$v.user?.toBuilder(); + final $v = _$v; + if ($v != null) { + _role = $v.role; + _type = $v.type; + _id = $v.id; + _courseId = $v.courseId; + _courseSectionId = $v.courseSectionId; + _enrollmentState = $v.enrollmentState; + _userId = $v.userId; + _grades = $v.grades?.toBuilder(); + _computedCurrentScore = $v.computedCurrentScore; + _computedFinalScore = $v.computedFinalScore; + _computedCurrentGrade = $v.computedCurrentGrade; + _computedFinalGrade = $v.computedFinalGrade; + _computedCurrentLetterGrade = $v.computedCurrentLetterGrade; + _multipleGradingPeriodsEnabled = $v.multipleGradingPeriodsEnabled; + _totalsForAllGradingPeriodsOption = $v.totalsForAllGradingPeriodsOption; + _currentPeriodComputedCurrentScore = $v.currentPeriodComputedCurrentScore; + _currentPeriodComputedFinalScore = $v.currentPeriodComputedFinalScore; + _currentPeriodComputedCurrentGrade = $v.currentPeriodComputedCurrentGrade; + _currentPeriodComputedFinalGrade = $v.currentPeriodComputedFinalGrade; + _currentGradingPeriodId = $v.currentGradingPeriodId; + _currentGradingPeriodTitle = $v.currentGradingPeriodTitle; + _associatedUserId = $v.associatedUserId; + _lastActivityAt = $v.lastActivityAt; + _limitPrivilegesToCourseSection = $v.limitPrivilegesToCourseSection; + _observedUser = $v.observedUser?.toBuilder(); + _user = $v.user?.toBuilder(); _$v = null; } return this; @@ -696,9 +684,7 @@ class EnrollmentBuilder implements Builder { @override void replace(Enrollment other) { - if (other == null) { - throw new ArgumentError.notNull('other'); - } + ArgumentError.checkNotNull(other, 'other'); _$v = other as _$Enrollment; } @@ -715,19 +701,25 @@ class EnrollmentBuilder implements Builder { new _$Enrollment._( role: role, type: type, - id: id, + id: BuiltValueNullFieldError.checkNotNull(id, 'Enrollment', 'id'), courseId: courseId, courseSectionId: courseSectionId, - enrollmentState: enrollmentState, - userId: userId, + enrollmentState: BuiltValueNullFieldError.checkNotNull( + enrollmentState, 'Enrollment', 'enrollmentState'), + userId: BuiltValueNullFieldError.checkNotNull( + userId, 'Enrollment', 'userId'), grades: _grades?.build(), computedCurrentScore: computedCurrentScore, computedFinalScore: computedFinalScore, computedCurrentGrade: computedCurrentGrade, computedFinalGrade: computedFinalGrade, - multipleGradingPeriodsEnabled: multipleGradingPeriodsEnabled, - totalsForAllGradingPeriodsOption: + computedCurrentLetterGrade: computedCurrentLetterGrade, + multipleGradingPeriodsEnabled: BuiltValueNullFieldError.checkNotNull( + multipleGradingPeriodsEnabled, 'Enrollment', 'multipleGradingPeriodsEnabled'), + totalsForAllGradingPeriodsOption: BuiltValueNullFieldError.checkNotNull( totalsForAllGradingPeriodsOption, + 'Enrollment', + 'totalsForAllGradingPeriodsOption'), currentPeriodComputedCurrentScore: currentPeriodComputedCurrentScore, currentPeriodComputedFinalScore: currentPeriodComputedFinalScore, @@ -736,9 +728,13 @@ class EnrollmentBuilder implements Builder { currentPeriodComputedFinalGrade: currentPeriodComputedFinalGrade, currentGradingPeriodId: currentGradingPeriodId, currentGradingPeriodTitle: currentGradingPeriodTitle, - associatedUserId: associatedUserId, + associatedUserId: BuiltValueNullFieldError.checkNotNull( + associatedUserId, 'Enrollment', 'associatedUserId'), lastActivityAt: lastActivityAt, - limitPrivilegesToCourseSection: limitPrivilegesToCourseSection, + limitPrivilegesToCourseSection: BuiltValueNullFieldError.checkNotNull( + limitPrivilegesToCourseSection, + 'Enrollment', + 'limitPrivilegesToCourseSection'), observedUser: _observedUser?.build(), user: _user?.build()); } catch (_) { @@ -762,4 +758,4 @@ class EnrollmentBuilder implements Builder { } } -// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,deprecated_member_use_from_same_package,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt index 1cde0ae668..122ec49631 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt @@ -20,6 +20,7 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addCourseWithEnrollment import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.CourseSettings import com.instructure.canvasapi2.models.Enrollment import com.instructure.espresso.page.getStringFromResource import com.instructure.panda_annotations.FeatureCategory @@ -134,6 +135,49 @@ class ElementaryGradesInteractionTest : StudentTest() { gradesPage.assertCourseShownWithGrades(notGradedCourse.name, "0%") } + @Test + @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + fun testDontShowProgressWhenQuantitativeDataIsRestricted() { + val data = createMockData(courseCount = 1) + goToGradesTab(data) + + gradesPage.assertPageObjects() + + var course = data.addCourseWithEnrollment( + data.students[0], + Enrollment.EnrollmentType.Student, + 50.0, + "C+", + restrictQuantitativeData = true + ) + + gradesPage.refresh() + + gradesPage.assertCourseShownWithGrades(course.name, "C+") + gradesPage.assertProgressNotDisplayed(course.name) + } + + @Test + @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + fun testDontShowGradeWhenQuantitativeDataIsRestrictedAndThereIsOnlyScore() { + val data = createMockData(courseCount = 1) + goToGradesTab(data) + + gradesPage.assertPageObjects() + + var course = data.addCourseWithEnrollment( + data.students[0], + Enrollment.EnrollmentType.Student, + 50.0, + "", + restrictQuantitativeData = true + ) + + gradesPage.refresh() + + gradesPage.assertCourseShownWithGrades(course.name, "--") + } + private fun createMockData( courseCount: Int = 0, withGradingPeriods: Boolean = false, diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt index 8f7e7d165b..1bb0b641b4 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt @@ -18,6 +18,7 @@ package com.instructure.student.ui.pages import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.hasSibling import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertNotDisplayed @@ -77,6 +78,11 @@ class GradesPage : BasePage(R.id.gradesPage) { gradesRecyclerView.assertNotDisplayed() } + fun assertProgressNotDisplayed(courseName: String) { + val courseNameMatcher = withId(R.id.gradesCourseNameText) + withText(courseName) + onView(withId(R.id.progressLayout) + hasSibling(courseNameMatcher)).assertNotDisplayed() + } + fun clickGradeRow(courseName: String) { onView(withId(R.id.gradesCourseNameText) + withText(courseName)) .scrollTo() diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt index 6e3a71bdb0..cc3230943c 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt @@ -459,8 +459,15 @@ fun MockCanvas.updateUserEnrollments() { } } -fun MockCanvas.addCourseWithEnrollment(user: User, enrollmentType: Enrollment.EnrollmentType, score: Double = 0.0, grade: String = "", isHomeroom: Boolean = false): Course { - val course = addCourse(isHomeroom = isHomeroom) +fun MockCanvas.addCourseWithEnrollment( + user: User, + enrollmentType: Enrollment.EnrollmentType, + score: Double = 0.0, + grade: String = "", + isHomeroom: Boolean = false, + restrictQuantitativeData: Boolean = false +): Course { + val course = addCourse(isHomeroom = isHomeroom, restrictQuantitativeData = restrictQuantitativeData) addEnrollment( user = user, @@ -482,7 +489,8 @@ fun MockCanvas.addCourse( section: Section? = null, isPublic: Boolean = true, withGradingPeriod: Boolean = false, - isHomeroom: Boolean = false + isHomeroom: Boolean = false, + restrictQuantitativeData: Boolean = false ): Course { val randomCourseName = Randomizer.randomCourseName() val endAt = if (concluded) OffsetDateTime.now().minusWeeks(1).toApiString() else null @@ -508,7 +516,8 @@ fun MockCanvas.addCourse( homeroomCourse = isHomeroom, gradingPeriods = gradingPeriodList, courseColor = "#008EE2", - restrictEnrollmentsToCourseDate = concluded + restrictEnrollmentsToCourseDate = concluded, + settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData) ) courses += course.id to course diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt index 721d76d05e..0a57121bbe 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt @@ -121,7 +121,7 @@ object CourseAPI { @GET("courses/{courseId}/rubrics/{rubricId}") fun getRubricSettings(@Path("courseId") courseId: Long, @Path("rubricId") rubricId: Long): Call - @GET("courses?include[]=total_scores&include[]=current_grading_period_scores&include[]=grading_periods&include[]=course_image&enrollment_state=active") + @GET("courses?include[]=total_scores&include[]=current_grading_period_scores&include[]=grading_periods&include[]=course_image&include[]=settings&enrollment_state=active") fun getFirstPageCoursesWithGrades(): Call> } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Enrollment.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Enrollment.kt index a55a96b851..38da34b47e 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Enrollment.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Enrollment.kt @@ -45,6 +45,8 @@ data class Enrollment( val computedCurrentGrade: String? = null, @SerializedName("computed_final_grade") val computedFinalGrade: String? = null, + @SerializedName("computed_current_letter_grade") + val computedCurrentLetterGrade: String? = null, @SerializedName("multiple_grading_periods_enabled") val multipleGradingPeriodsEnabled: Boolean = false, @SerializedName("totals_for_all_grading_periods_option") @@ -103,7 +105,7 @@ data class Enrollment( val currentScore: Double? get() = grades?.currentScore ?: computedCurrentScore val finalScore: Double? get() = grades?.finalScore ?: computedFinalScore - val currentGrade: String? get() = grades?.currentGrade ?: computedCurrentGrade + val currentGrade: String? get() = grades?.currentGrade ?: computedCurrentGrade ?: computedCurrentLetterGrade val finalGrade: String? get() = grades?.finalGrade ?: computedFinalGrade fun currentPeriodComputedCurrentScore(): Double? = grades?.currentScore ?: currentPeriodComputedCurrentScore diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewData.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewData.kt index cabebc8425..e942a573f2 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewData.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewData.kt @@ -31,7 +31,8 @@ data class GradeRowViewData( val courseColor: ThemedColor, val courseImageUrl: String, val score: Double?, - val gradeText: String) + val gradeText: String, + val hideProgress: Boolean = false) sealed class GradesAction { data class OpenCourseGrades(val course: Course) : GradesAction() diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewModel.kt index 5f0cdd6a9d..686a6def4b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewModel.kt @@ -132,6 +132,8 @@ class GradesViewModel @Inject constructor( .map { val enrollment = it.enrollments?.first() val grades = it.getCourseGrade(false) + val restrictQuantitativeData = it.settings?.restrictQuantitativeData ?: false + val notGraded = (enrollment?.currentGradingPeriodId ?: 0L) != 0L GradeRowItemViewModel(resources, GradeRowViewData( it.id, @@ -139,7 +141,14 @@ class GradesViewModel @Inject constructor( colorKeeper.getOrGenerateColor(it), it.imageUrl ?: "", if (it.hideFinalGrades) 0.0 else grades?.currentScore, - createGradeText(grades?.currentScore, grades?.currentGrade, it.hideFinalGrades, enrollment?.currentGradingPeriodId ?: 0L != 0L)) + createGradeText( + grades?.currentScore, + grades?.currentGrade, + it.hideFinalGrades, + notGraded = notGraded, + restrictQuantitativeData = restrictQuantitativeData + ), + hideProgress = restrictQuantitativeData || notGraded || it.hideFinalGrades) ) { gradeRowClicked(it) } } } @@ -155,14 +164,14 @@ class GradesViewModel @Inject constructor( return GradesViewData(items) } - private fun createGradeText(score: Double?, grade: String?, hideFinalGrades: Boolean, notGraded: Boolean = true): String { + private fun createGradeText(score: Double?, grade: String?, hideFinalGrades: Boolean, notGraded: Boolean = true, restrictQuantitativeData: Boolean = true): String { return when { hideFinalGrades -> "--" !grade.isNullOrEmpty() -> grade else -> { val currentScoreRounded = score?.roundToInt() when { - currentScoreRounded != null -> "$currentScoreRounded%" + currentScoreRounded != null && !restrictQuantitativeData -> "$currentScoreRounded%" notGraded -> resources.getString(R.string.notGraded) else -> "--" } @@ -236,13 +245,15 @@ class GradesViewModel @Inject constructor( } private fun createGradeRowFromEnrollment(course: Course, enrollment: Enrollment?): GradeRowItemViewModel { + val restrictQuantitativeData = course.settings?.restrictQuantitativeData ?: false val gradeRowViewData = GradeRowViewData( course.id, course.name, colorKeeper.getOrGenerateColor(course), course.imageUrl ?: "", enrollment?.grades?.currentScore, - createGradeText(enrollment?.grades?.currentScore, enrollment?.grades?.currentGrade, course.hideFinalGrades)) + createGradeText(enrollment?.grades?.currentScore, enrollment?.grades?.currentGrade, course.hideFinalGrades, restrictQuantitativeData), + restrictQuantitativeData || course.hideFinalGrades) return GradeRowItemViewModel(resources, gradeRowViewData) { gradeRowClicked(course) } } diff --git a/libs/pandautils/src/main/res/layout-sw720dp/item_grade_row.xml b/libs/pandautils/src/main/res/layout-sw720dp/item_grade_row.xml index 6a589cbc65..5b98f2c63b 100644 --- a/libs/pandautils/src/main/res/layout-sw720dp/item_grade_row.xml +++ b/libs/pandautils/src/main/res/layout-sw720dp/item_grade_row.xml @@ -101,6 +101,7 @@ android:layout_marginTop="6dp" android:layout_marginStart="12dp" android:layout_marginEnd="8dp" + app:visible="@{!itemViewModel.data.hideProgress}" app:layout_constraintStart_toEndOf="@id/courseImage" app:layout_constraintTop_toBottomOf="@id/gradesCourseNameText" app:layout_constraintEnd_toStartOf="@id/chevronIcon"> diff --git a/libs/pandautils/src/main/res/layout/item_grade_row.xml b/libs/pandautils/src/main/res/layout/item_grade_row.xml index f1e3bc69bb..4454b86170 100644 --- a/libs/pandautils/src/main/res/layout/item_grade_row.xml +++ b/libs/pandautils/src/main/res/layout/item_grade_row.xml @@ -73,7 +73,8 @@ android:src="@drawable/ic_chevron_right" android:layout_marginEnd="8dp" android:tint="@color/textDark" - app:layout_constraintTop_toBottomOf="@id/gradesCourseNameText" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" /> diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/grades/GradesViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/grades/GradesViewModelTest.kt index a89b984626..28722f89c5 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/grades/GradesViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/grades/GradesViewModelTest.kt @@ -23,8 +23,8 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.managers.EnrollmentManager -import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.CourseSettings import com.instructure.canvasapi2.models.Enrollment import com.instructure.canvasapi2.models.GradingPeriod import com.instructure.canvasapi2.utils.DataResult @@ -32,10 +32,8 @@ import com.instructure.pandautils.R import com.instructure.pandautils.features.elementary.grades.itemviewmodels.GradeRowItemViewModel import com.instructure.pandautils.features.elementary.grades.itemviewmodels.GradingPeriodSelectorItemViewModel import com.instructure.pandautils.mvvm.ViewState -import com.instructure.pandautils.utils.ColorApiHelper import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.ThemedColor -import com.instructure.pandautils.utils.textAndIconColor import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -142,7 +140,7 @@ class GradesViewModelTest { val expectedGradeRow1 = GradeRowViewData(1, "Course with Grade", ThemedColor(0), "www.1.com", 90.0, "A") val expectedGradeRow2 = GradeRowViewData(2, "Course with Score", ThemedColor(0), "www.1.com", 75.6, "76%") val expectedGradeRow3 = GradeRowViewData(3, "Course without scores", ThemedColor(0), "www.1.com", null, "--") - val expectedGradeRow4 = GradeRowViewData(4, "Hide Final Grades", ThemedColor(0), "www.1.com", 0.0, "--") + val expectedGradeRow4 = GradeRowViewData(4, "Hide Final Grades", ThemedColor(0), "www.1.com", 0.0, "--", hideProgress = true) assertEquals(expectedGradeRow1, gradeRows[0].data) assertEquals(expectedGradeRow2, gradeRows[1].data) @@ -347,6 +345,57 @@ class GradesViewModelTest { assertEquals(GradesAction.OpenGradingPeriodsDialog(expectedGradingPeriods, 0), viewModel.events.value!!.getContentIfNotHandled()) } + @Test + fun `Hide progress when quantitative data is restricted`() { + // Given + val course = createCourseWithGrades(1, "Course with Grade", "", "www.1.com", 90.0, "A") + .copy(settings = CourseSettings(restrictQuantitativeData = true)) + + every { courseManager.getCoursesWithGradesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(listOf(course)) + } + + // When + viewModel = createViewModel() + viewModel.state.observe(lifecycleOwner, {}) + + // Then + assertTrue(viewModel.state.value is ViewState.Success) + assertEquals(1, viewModel.data.value!!.items.size) + + val gradeRows = viewModel.data.value!!.items.map { it as GradeRowItemViewModel } + + val expectedGradeRow1 = GradeRowViewData(1, "Course with Grade", ThemedColor(0), "www.1.com", 90.0, "A", hideProgress = true) + + assertEquals(expectedGradeRow1, gradeRows[0].data) + } + + @Test + fun `Do not show score when quantitative data is restricted and there is no grade`() { + // Given + val course = createCourseWithGrades(1, "Course with Grade", "", "www.1.com", 90.0, null) + .copy(settings = CourseSettings(restrictQuantitativeData = true)) + + every { courseManager.getCoursesWithGradesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(listOf(course)) + } + + // When + viewModel = createViewModel() + viewModel.state.observe(lifecycleOwner, {}) + + // Then + assertTrue(viewModel.state.value is ViewState.Success) + assertEquals(1, viewModel.data.value!!.items.size) + + val gradeRows = viewModel.data.value!!.items.map { it as GradeRowItemViewModel } + + val expectedGradeRow1 = GradeRowViewData(1, "Course with Grade", ThemedColor(0), "www.1.com", 90.0, "--", hideProgress = true) + + assertEquals(expectedGradeRow1, gradeRows[0].data) + } + + private fun createViewModel() = GradesViewModel(courseManager, resources, enrollmentManager, colorKeeper) private fun createCourseWithGrades( From 8f5b051efeabbca2093b88d4f90f5edfc06c7441 Mon Sep 17 00:00:00 2001 From: "kristof.nemere" Date: Fri, 11 Aug 2023 10:13:09 +0200 Subject: [PATCH 30/61] Version bump --- apps/flutter_parent/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flutter_parent/pubspec.yaml b/apps/flutter_parent/pubspec.yaml index 021b319332..c87f6a3c72 100644 --- a/apps/flutter_parent/pubspec.yaml +++ b/apps/flutter_parent/pubspec.yaml @@ -25,7 +25,7 @@ description: Canvas Parent # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.7.0+45 +version: 3.8.0+46 module: androidX: true From df0b883ccbeb7b3f20ed4d73dca1bb74dcbd8f3a Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Fri, 11 Aug 2023 10:50:57 +0200 Subject: [PATCH 31/61] [MBL-16956][Student] Rubric LGO refs: MBL-16956 affects: Student release note: none * Rubric LGO * fixed tests * fixed pr comment --- .../SubmissionDetailsEffectHandler.kt | 17 +++++- .../SubmissionDetailsModels.kt | 6 ++- .../SubmissionDetailsPresenter.kt | 3 +- .../SubmissionDetailsUpdate.kt | 3 +- .../drawer/rubric/SubmissionRubricModels.kt | 1 + .../rubric/SubmissionRubricPresenter.kt | 12 ++--- .../rubric/ui/SubmissionRubricFragment.kt | 8 ++- .../ui/SubmissionDetailsViewState.kt | 3 +- .../ui/gradeCell/GradeCellViewState.kt | 28 +++++++--- .../assignment/details/GradeCellStateTest.kt | 52 +++++++++++++++++++ .../SubmissionDetailsEffectHandlerTest.kt | 4 ++ .../SubmissionRubricPresenterTest.kt | 21 ++++++++ 12 files changed, 137 insertions(+), 21 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt index 1c7bb33fc3..bd88b91d10 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt @@ -122,7 +122,22 @@ class SubmissionDetailsEffectHandler : EffectHandler?, val studioLTIToolResult: DataResult?, val isObserver: Boolean = false, - val assignmentEnhancementsEnabled: Boolean + val assignmentEnhancementsEnabled: Boolean, + val restrictQuantitativeData: Boolean = false ) : SubmissionDetailsEvent() data class SubmissionCommentsUpdated(val submissionComments: List) : SubmissionDetailsEvent() } @@ -74,7 +75,8 @@ data class SubmissionDetailsModel( val ltiTool: DataResult? = null, val initialSelectedSubmissionAttempt: Long? = null, val submissionComments: List? = null, - val assignmentEnhancementsEnabled: Boolean = false + val assignmentEnhancementsEnabled: Boolean = false, + val restrictQuantitativeData: Boolean = false ) sealed class SubmissionDetailsContentType { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsPresenter.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsPresenter.kt index 3bd581fc50..cade1dc24a 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsPresenter.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsPresenter.kt @@ -80,7 +80,8 @@ object SubmissionDetailsPresenter : Presenter = emptyMap() ) diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/SubmissionRubricPresenter.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/SubmissionRubricPresenter.kt index 0cad9f9bb6..a16d2f8595 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/SubmissionRubricPresenter.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/SubmissionRubricPresenter.kt @@ -24,12 +24,10 @@ import com.instructure.canvasapi2.models.RubricCriterionRating import com.instructure.canvasapi2.utils.NumberHelper import com.instructure.canvasapi2.utils.isValid import com.instructure.canvasapi2.utils.validOrNull -import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.R import com.instructure.student.mobius.assignmentDetails.ui.gradeCell.GradeCellViewState import com.instructure.student.mobius.common.ui.Presenter -import java.util.HashMap object SubmissionRubricPresenter : Presenter { @@ -46,7 +44,7 @@ object SubmissionRubricPresenter : Presenter RatingData( diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricFragment.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricFragment.kt index 053e9241ce..a8268a84c0 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricFragment.kt @@ -22,6 +22,7 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Submission import com.instructure.pandautils.analytics.SCREEN_VIEW_SUBMISSION_RUBRIC import com.instructure.pandautils.analytics.ScreenView +import com.instructure.pandautils.utils.BooleanArg import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ParcelableArg import com.instructure.student.databinding.FragmentSubmissionRubricBinding @@ -29,11 +30,14 @@ import com.instructure.student.mobius.assignmentDetails.submissionDetails.drawer import com.instructure.student.mobius.assignmentDetails.submissionDetails.ui.SubmissionDetailsTabData import com.instructure.student.mobius.common.ui.MobiusFragment +private const val RESTRICT_QUANTITATIVE_DATA = "restrictQuantitativeData" + @ScreenView(SCREEN_VIEW_SUBMISSION_RUBRIC) class SubmissionRubricFragment : MobiusFragment() { private var submission by ParcelableArg(key = Const.SUBMISSION) private var assignment by ParcelableArg(key = Const.ASSIGNMENT) + private var restrictQuantitativeData by BooleanArg(key = RESTRICT_QUANTITATIVE_DATA) override fun makeEffectHandler() = SubmissionRubricEffectHandler() @@ -43,13 +47,13 @@ class SubmissionRubricFragment : override fun makePresenter() = SubmissionRubricPresenter - override fun makeInitModel() = SubmissionRubricModel(assignment, submission) + override fun makeInitModel() = SubmissionRubricModel(assignment, submission, restrictQuantitativeData) companion object { fun newInstance(data: SubmissionDetailsTabData.RubricData) = SubmissionRubricFragment().apply { submission = data.submission assignment = data.assignment + restrictQuantitativeData = data.restrictQuantitativeData } } - } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsViewState.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsViewState.kt index 2fb7cfcd08..a956c7e85f 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsViewState.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsViewState.kt @@ -50,7 +50,8 @@ sealed class SubmissionDetailsTabData(val tabName: String) { data class RubricData( val name: String, val assignment: Assignment, - val submission: Submission + val submission: Submission, + val restrictQuantitativeData: Boolean = false ) : SubmissionDetailsTabData(name) } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt index 0f0bc8b7e9..42cf48e75c 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt @@ -24,7 +24,6 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.NumberHelper -import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.getContentDescriptionForMinusGradeString import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.R @@ -69,10 +68,12 @@ sealed class GradeCellViewState { fun fromSubmission( context: Context, assignment: Assignment, - submission: Submission? + submission: Submission?, + restrictQuantitativeData: Boolean = false ): GradeCellViewState { - // Return empty state if unsubmitted and ungraded, or "Not Graded" grading type - if ((submission?.submittedAt == null && submission?.isGraded != true) || assignment.gradingType == Assignment.NOT_GRADED_TYPE) { + // Return empty state if unsubmitted and ungraded, or "Not Graded" grading type or quantitative data is restricted + val hideGrades = restrictQuantitativeData && assignment.isGradingTypeQuantitative && submission?.excused != true + if ((submission?.submittedAt == null && submission?.isGraded != true) || assignment.gradingType == Assignment.NOT_GRADED_TYPE || hideGrades) { return Empty } @@ -87,8 +88,8 @@ sealed class GradeCellViewState { /* The 'Out of' text abbreviates the word "points" to "pts" which is read as "P T S" by screen readers, so * we use a second string with the full word "points" as a content description. */ val pointsPossibleText = NumberHelper.formatDecimal(assignment.pointsPossible, 2, true) - val outOfText = context.getString(R.string.outOfPointsAbbreviatedFormatted, pointsPossibleText) - val outOfContentDescriptionText = context.getString(R.string.outOfPointsFormatted, pointsPossibleText) + val outOfText = if (restrictQuantitativeData) "" else context.getString(R.string.outOfPointsAbbreviatedFormatted, pointsPossibleText) + val outOfContentDescriptionText = if (restrictQuantitativeData) "" else context.getString(R.string.outOfPointsFormatted, pointsPossibleText) // Excused if (submission.excused) { @@ -116,6 +117,21 @@ sealed class GradeCellViewState { ) } + if (restrictQuantitativeData) { + val grade = submission.grade.orEmpty() + val accessibleGradeString = getContentDescriptionForMinusGradeString(grade, context) + val gradeCellContentDescription = context.getString(R.string.a11y_gradeCellContentDescriptionLetterGradeOnly, accessibleGradeString) + + return GradeData( + showCompleteIcon = true, + graphPercent = 1.0f, + accentColor = accentColor, + grade = grade, + gradeContentDescription = accessibleGradeString, + gradeCellContentDescription = gradeCellContentDescription + ) + } + val score = NumberHelper.formatDecimal(submission.enteredScore, 2, true) val graphPercent = (submission.enteredScore / assignment.pointsPossible).coerceIn(0.0, 1.0).toFloat() diff --git a/apps/student/src/test/java/com/instructure/student/test/assignment/details/GradeCellStateTest.kt b/apps/student/src/test/java/com/instructure/student/test/assignment/details/GradeCellStateTest.kt index f2ab5c245c..461a326251 100644 --- a/apps/student/src/test/java/com/instructure/student/test/assignment/details/GradeCellStateTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/assignment/details/GradeCellStateTest.kt @@ -140,6 +140,8 @@ class GradeCellStateTest : Assert() { graphPercent = 1.0f, showCompleteIcon = true, grade = "Excused", + outOf = "Out of 100 pts", + outOfContentDescription = "Out of 100 points", gradeCellContentDescription = "" ) val actual = GradeCellViewState.fromSubmission(context, baseAssignment, submission) @@ -243,6 +245,8 @@ class GradeCellStateTest : Assert() { showPointsLabel = true, grade = "B+", gradeContentDescription = "B+", + outOf = "Out of 100 pts", + outOfContentDescription = "Out of 100 points", gradeCellContentDescription = "85 Out of 100 points, B+" ) val actual = GradeCellViewState.fromSubmission(context, assignment, submission) @@ -388,4 +392,52 @@ class GradeCellStateTest : Assert() { assertEquals(expected, actual.stats) } + @Test + fun `Returns empty state when assignment is quantitative and quantitative data is restricted`() { + val assignment = baseAssignment.copy( + gradingType = Assignment.POINTS_TYPE + ) + val expected = GradeCellViewState.Empty + val actual = GradeCellViewState.fromSubmission(context, assignment, Submission(), true) + assertEquals(expected, actual) + } + + @Test + fun `Create excused grade cell without points when assignment is quantitative and quantitative data is restricted`() { + val submission = baseSubmission.copy( + excused = true + ) + val expected = baseGradedState.copy( + graphPercent = 1.0f, + showCompleteIcon = true, + grade = "Excused", + outOf = "", + outOfContentDescription = "", + gradeCellContentDescription = "" + ) + val actual = GradeCellViewState.fromSubmission(context, baseAssignment, submission, true) + assertEquals(expected, actual) + } + + @Test + fun `Create letter grade cell without points when quantitative data is restricted`() { + val assignment = baseAssignment.copy( + gradingType = Assignment.LETTER_GRADE_TYPE + ) + val submission = baseSubmission.copy( + grade = "B+" + ) + val expected = baseGradedState.copy( + graphPercent = 1.0f, + score = "", + showCompleteIcon = true, + grade = "B+", + gradeContentDescription = "B+", + outOf = "", + outOfContentDescription = "", + gradeCellContentDescription = "Grade: B+" + ) + val actual = GradeCellViewState.fromSubmission(context, assignment, submission, true) + assertEquals(expected, actual) + } } diff --git a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEffectHandlerTest.kt b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEffectHandlerTest.kt index 1ada21d48d..93ea7990e7 100644 --- a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEffectHandlerTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEffectHandlerTest.kt @@ -51,9 +51,13 @@ class SubmissionDetailsEffectHandlerTest : Assert() { fun setup() { Dispatchers.setMain(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) mockkObject(FeaturesManager) + mockkObject(CourseManager) every { FeaturesManager.getEnabledFeaturesForCourseAsync(any(), any()) } returns mockk { coEvery { await() } returns DataResult.Success(listOf("assignments_2_student")) } + every { CourseManager.getCourseSettingsAsync(any(), any()) } returns mockk { + coEvery { await() } returns DataResult.Success(CourseSettings()) + } } @Test diff --git a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/rubricTab/SubmissionRubricPresenterTest.kt b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/rubricTab/SubmissionRubricPresenterTest.kt index 3f8690ec89..b6fbee35fb 100644 --- a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/rubricTab/SubmissionRubricPresenterTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/rubricTab/SubmissionRubricPresenterTest.kt @@ -455,4 +455,25 @@ class SubmissionRubricPresenterTest : Assert() { val actualState = SubmissionRubricPresenter.present(model, context) assertEquals(expectedState, actualState) } + + @Test + fun `Returns correct state when quantitative data is restricted`() { + val model = modelTemplate.copy( + restrictQuantitativeData = true + ) + val expectedState = SubmissionRubricViewState( + listOf( + RubricListData.Grade(GradeCellViewState.fromSubmission(context, assignmentTemplate, submissionTemplate, true)), + criterionTemplate.copy( + ratings = listOf( + RatingData("_id1", "Rating 1 Title", isSelected = false, isAssessed = false, useSmallText = true), + RatingData("_id2", "Rating 2 Title", isSelected = true, isAssessed = true, useSmallText = true), + RatingData("_id3", "Rating 3 Title", isSelected = false, isAssessed = false, useSmallText = true) + ) + ) + ) + ) + val actualState = SubmissionRubricPresenter.present(model, context) + assertEquals(expectedState, actualState) + } } From 029f2cea056bbaa2415577ba4aacf6a686fe733f Mon Sep 17 00:00:00 2001 From: "kristof.nemere" Date: Fri, 11 Aug 2023 12:52:29 +0200 Subject: [PATCH 32/61] Fixed alerts filtering issue --- apps/flutter_parent/lib/utils/alert_helper.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flutter_parent/lib/utils/alert_helper.dart b/apps/flutter_parent/lib/utils/alert_helper.dart index c2c378792d..cdd947e54f 100644 --- a/apps/flutter_parent/lib/utils/alert_helper.dart +++ b/apps/flutter_parent/lib/utils/alert_helper.dart @@ -26,7 +26,7 @@ class AlertsHelper { filteredList.add(element); } else { Course course = await locator().getCourse(courseId, forceRefresh: false); - if (!course.settings.restrictQuantitativeData) { + if (!(course.settings?.restrictQuantitativeData ?? false)) { filteredList.add(element); } } From 1709bcf466ea4f849c4110fb06d4900ab57a8d71 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Tue, 15 Aug 2023 14:46:41 +0200 Subject: [PATCH 33/61] [MBL-16941][Student][Teacher] - Introduce SearchablePage interface (#2090) --- .../student/ui/e2e/AnnouncementsE2ETest.kt | 10 ++-- .../student/ui/e2e/AssignmentsE2ETest.kt | 4 +- .../student/ui/e2e/DiscussionsE2ETest.kt | 6 +-- .../student/ui/e2e/FilesE2ETest.kt | 8 ++- .../student/ui/e2e/PagesE2ETest.kt | 20 ++++++++ .../student/ui/e2e/QuizzesE2ETest.kt | 1 + .../AnnouncementInteractionTest.kt | 19 +++++-- .../student/ui/pages/AnnouncementListPage.kt | 2 +- .../student/ui/pages/AssignmentListPage.kt | 34 +++++++++---- .../student/ui/pages/DiscussionListPage.kt | 17 +------ .../student/ui/pages/FileListPage.kt | 23 ++------- .../student/ui/pages/PageListPage.kt | 24 ++++++--- .../student/ui/utils/StudentTest.kt | 9 ++-- .../teacher/ui/AnnouncementsListPageTest.kt | 8 +-- .../teacher/ui/DiscussionsListPageTest.kt | 8 +-- .../teacher/ui/PageListPageTest.kt | 4 +- .../teacher/ui/QuizListPageTest.kt | 4 +- .../teacher/ui/e2e/AnnouncementsE2ETest.kt | 6 +-- .../teacher/ui/e2e/DiscussionsE2ETest.kt | 15 ++++-- .../teacher/ui/e2e/FilesE2ETest.kt | 12 ++--- .../teacher/ui/e2e/PagesE2ETest.kt | 8 ++- .../teacher/ui/e2e/PeopleE2ETest.kt | 8 +-- .../teacher/ui/pages/AnnouncementsListPage.kt | 50 +------------------ .../teacher/ui/pages/DiscussionsListPage.kt | 25 ++-------- .../teacher/ui/pages/FileListPage.kt | 35 +------------ .../teacher/ui/pages/PageListPage.kt | 25 +--------- .../teacher/ui/pages/PeopleListPage.kt | 27 +--------- .../teacher/ui/pages/QuizListPage.kt | 20 +------- .../teacher/ui/utils/TeacherTest.kt | 14 +++--- .../espresso/CustomViewAssertions.kt | 20 ++++++++ .../com/instructure/espresso/Searchable.kt | 25 ++++++++++ .../espresso/matchers/WaitForViewMatcher.kt | 4 +- 32 files changed, 209 insertions(+), 286 deletions(-) create mode 100644 automation/espresso/src/main/kotlin/com/instructure/espresso/Searchable.kt diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt index d34e9611d7..65b44544cd 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt @@ -92,8 +92,8 @@ class AnnouncementsE2ETest : StudentTest() { Log.d(STEP_TAG,"Click on Search button and type ${announcement.title} to the search input field.") Espresso.pressBack() - discussionListPage.clickOnSearchButton() - discussionListPage.typeToSearchBar(announcement.title) + discussionListPage.searchable.clickOnSearchButton() + discussionListPage.searchable.typeToSearchBar(announcement.title) Log.d(STEP_TAG,"Assert that only the matching announcement is displayed on the Discussion List Page.") discussionListPage.pullToUpdate() @@ -101,12 +101,12 @@ class AnnouncementsE2ETest : StudentTest() { discussionListPage.assertTopicNotDisplayed(lockedAnnouncement.title) Log.d(STEP_TAG,"Clear search input field value and assert if all the announcements are displayed again on the Discussion List Page.") - discussionListPage.clickOnClearSearchButton() + discussionListPage.searchable.clickOnClearSearchButton() discussionListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) discussionListPage.assertTopicDisplayed(announcement.title) Log.d(STEP_TAG,"Type a search value to the search input field which does not much with any of the existing announcements.") - discussionListPage.typeToSearchBar("Non existing announcement title") + discussionListPage.searchable.typeToSearchBar("Non existing announcement title") sleep(3000) //We need this wait here to let make sure the search process has finished. Log.d(STEP_TAG,"Assert that the empty view is displayed and none of the announcements are appearing on the page.") @@ -115,7 +115,7 @@ class AnnouncementsE2ETest : StudentTest() { discussionListPage.assertTopicNotDisplayed(lockedAnnouncement.title) Log.d(STEP_TAG,"Clear search input field value and assert if all the announcements are displayed again on the Discussion List Page.") - discussionListPage.clickOnClearSearchButton() + discussionListPage.searchable.clickOnClearSearchButton() discussionListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) discussionListPage.assertTopicDisplayed(announcement.title) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt index 2d98a6a603..018271a8d4 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt @@ -386,10 +386,10 @@ class AssignmentsE2ETest: StudentTest() { assignmentListPage.expandCollapseAssignmentGroup("Assignments") Log.d(STEP_TAG, "Click on the 'Search' (magnifying glass) icon at the toolbar.") - assignmentListPage.clickOnSearchButton() + assignmentListPage.searchable.clickOnSearchButton() Log.d(STEP_TAG, "Type the name of the '${missingAssignment.name}' assignment.") - assignmentListPage.typeToSearchBar(missingAssignment.name.drop(5)) + assignmentListPage.searchable.typeToSearchBar(missingAssignment.name.drop(5)) Log.d(STEP_TAG, "Assert that the '${missingAssignment.name}' assignment has been found by previously typed search string.") sleep(3000) // Allow the search input to propagate diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt index 891abc37ce..85506cb028 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt @@ -88,8 +88,8 @@ class DiscussionsE2ETest: StudentTest() { Espresso.pressBack() Log.d(STEP_TAG,"Click on the 'Search' button and search for ${announcement2.title}. announcement.") - discussionListPage.clickOnSearchButton() - discussionListPage.typeToSearchBar(announcement2.title) + discussionListPage.searchable.clickOnSearchButton() + discussionListPage.searchable.typeToSearchBar(announcement2.title) Log.d(STEP_TAG,"Refresh the page. Assert that the searching method is working well, so ${announcement.title} won't be displayed and ${announcement2.title} is displayed.") discussionListPage.pullToUpdate() @@ -97,7 +97,7 @@ class DiscussionsE2ETest: StudentTest() { discussionListPage.assertTopicNotDisplayed(announcement.title) Log.d(STEP_TAG,"Clear the search input field and assert that both announcements, ${announcement.title} and ${announcement2.title} has been diplayed.") - discussionListPage.clickOnClearSearchButton() + discussionListPage.searchable.clickOnClearSearchButton() discussionListPage.waitForDiscussionTopicToDisplay(announcement.title) discussionListPage.assertTopicDisplayed(announcement2.title) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt index b7e1f53e7e..794c7dc02b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt @@ -20,7 +20,6 @@ import android.os.Environment import android.util.Log import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E -import com.instructure.canvas.espresso.refresh import com.instructure.canvasapi2.managers.DiscussionManager import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.DiscussionEntry @@ -186,14 +185,14 @@ class FilesE2ETest: StudentTest() { fileListPage.assertItemDisplayed("unfiled") // Our discussion attachment goes under "unfiled" Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${discussionAttachmentFile.name}', the file's name to the search input field.") - fileListPage.clickSearchButton() - fileListPage.typeSearchInput(discussionAttachmentFile.name) + fileListPage.searchable.clickOnSearchButton() + fileListPage.searchable.typeToSearchBar(discussionAttachmentFile.name) Log.d(STEP_TAG, "Assert that only 1 file matches for the search text, and it is '${discussionAttachmentFile.name}', and no directories has been shown in the result. Press search back button the quit from search result view.") fileListPage.assertSearchResultCount(1) fileListPage.assertItemDisplayed(discussionAttachmentFile.name) fileListPage.assertItemNotDisplayed("unfiled") - fileListPage.pressSearchBackButton() + fileListPage.searchable.pressSearchBackButton() Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") fileListPage.selectItem("unfiled") @@ -214,7 +213,6 @@ class FilesE2ETest: StudentTest() { Log.d(STEP_TAG, "Navigate back to global File List Page. Assert that the 'unfiled' folder has 0 items because we deleted the only item in it recently.") Espresso.pressBack() - refresh() //TODO after this bugfix: https://instructure.atlassian.net/browse/MBL-16937?atlOrigin=eyJpIjoiNWJjODY1MTI4NDE0NGQxM2E3ZjBiYTQzZDdlM2IwOWIiLCJwIjoiaiJ9 fileListPage.assertFolderSize("unfiled", 0) val testFolderName = "Krissinho's Test Folder" diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt index 1dac3ed502..2f48aaacd3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt @@ -81,6 +81,26 @@ class PagesE2ETest: StudentTest() { Log.d(STEP_TAG,"Assert that '${pageUnpublished.title}' unpublished page is NOT displayed.") pageListPage.assertPageNotDisplayed(pageUnpublished) + Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${pagePublishedFront.title}', the page's name to the search input field.") + pageListPage.searchable.clickOnSearchButton() + pageListPage.searchable.typeToSearchBar(pagePublishedFront.title) + + Log.d(STEP_TAG,"Assert that '${pagePublished.title}' published page is NOT displayed and there is only one page (the front page) is displayed.") + pageListPage.assertPageNotDisplayed(pagePublished) + pageListPage.assertPageListItemCount(1) + + Log.d(STEP_TAG, "Click on clear search icon (X).") + pageListPage.searchable.clickOnClearSearchButton() + + Log.d(STEP_TAG,"Assert that '${pagePublishedFront.title}' published front page is displayed.") + pageListPage.assertFrontPageDisplayed(pagePublishedFront) + + Log.d(STEP_TAG,"Assert that '${pagePublished.title}' published page is displayed.") + pageListPage.assertRegularPageDisplayed(pagePublished) + + Log.d(STEP_TAG,"Assert that '${pageUnpublished.title}' unpublished page is NOT displayed.") + pageListPage.assertPageNotDisplayed(pageUnpublished) + Log.d(STEP_TAG,"Open '${pagePublishedFront.title}' page. Assert that it is really a front (published) page via web view assertions.") pageListPage.selectFrontPage(pagePublishedFront) canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Front Page Text")) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt index 472584de69..13261cd33e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt @@ -248,4 +248,5 @@ class QuizzesE2ETest: StudentTest() { // answers = listOf() // ) ) + } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AnnouncementInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AnnouncementInteractionTest.kt index a857544b3b..187ff93b34 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AnnouncementInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AnnouncementInteractionTest.kt @@ -17,8 +17,17 @@ package com.instructure.student.ui.interaction import androidx.test.espresso.Espresso import androidx.test.espresso.web.webdriver.Locator -import com.instructure.canvas.espresso.mockCanvas.* -import com.instructure.canvasapi2.models.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addDiscussionTopicToCourse +import com.instructure.canvas.espresso.mockCanvas.addGroupToCourse +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.CanvasContextPermission +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.DiscussionTopicHeader +import com.instructure.canvasapi2.models.Group +import com.instructure.canvasapi2.models.Tab +import com.instructure.canvasapi2.models.User import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory @@ -219,14 +228,14 @@ class AnnouncementInteractionTest : StudentTest() { discussionListPage.createAnnouncement(testAnnouncementName, "description") discussionListPage.assertAnnouncementCreated(testAnnouncementName) - discussionListPage.clickOnSearchButton() - discussionListPage.typeToSearchBar(testAnnouncementName) + discussionListPage.searchable.clickOnSearchButton() + discussionListPage.searchable.typeToSearchBar(testAnnouncementName) discussionListPage.pullToUpdate() discussionListPage.assertTopicDisplayed(testAnnouncementName) discussionListPage.assertTopicNotDisplayed(existingAnnouncementName) - discussionListPage.clickOnClearSearchButton() + discussionListPage.searchable.clickOnClearSearchButton() discussionListPage.waitForDiscussionTopicToDisplay(existingAnnouncementName!!) discussionListPage.assertTopicDisplayed(testAnnouncementName) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt index d56e3f34e0..d4986a1d9e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt @@ -26,7 +26,7 @@ import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText import com.instructure.student.R -class AnnouncementListPage : BasePage(R.id.discussionListPage) { +class AnnouncementListPage() : BasePage(R.id.discussionListPage) { fun assertToolbarTitle() { WaitForViewMatcher.waitForView(withParent(R.id.discussionListToolbar) + withText(R.string.announcements)).assertDisplayed() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt index cb79a791e3..6244425c93 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt @@ -27,14 +27,34 @@ import com.instructure.canvas.espresso.waitForMatcherWithRefreshes import com.instructure.canvasapi2.models.Assignment import com.instructure.dataseeding.model.AssignmentApiModel import com.instructure.dataseeding.model.QuizApiModel -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.Searchable +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.WaitForViewWithText +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertHasText +import com.instructure.espresso.assertNotDisplayed +import com.instructure.espresso.assertVisible +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.waitForViewWithText +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withParent +import com.instructure.espresso.page.withText +import com.instructure.espresso.scrollTo +import com.instructure.espresso.swipeDown +import com.instructure.espresso.waitForCheck import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.containsString -class AssignmentListPage : BasePage(pageResId = R.id.assignmentListPage) { +class AssignmentListPage(val searchable: Searchable) : BasePage(pageResId = R.id.assignmentListPage) { private val assignmentListToolbar by OnViewWithId(R.id.toolbar) private val gradingPeriodHeader by WaitForViewWithId(R.id.termSpinnerLayout) @@ -84,14 +104,6 @@ class AssignmentListPage : BasePage(pageResId = R.id.assignmentListPage) { onView(withId(R.id.points) + withParent(hasSibling(pointsMatcher))).assertNotDisplayed() } - fun clickOnSearchButton() { - onView(withId(R.id.search)).click() - } - - fun typeToSearchBar(textToType: String) { - waitForViewWithId(R.id.search_src_text).replaceText(textToType) - } - fun assertAssignmentNotDisplayed(assignmentName: String) { onView(withText(assignmentName) + withId(R.id.title) + hasSibling(withId(R.id.description))).check(doesNotExist()) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt index 5e7d5468a3..3e6bebfce7 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt @@ -27,6 +27,7 @@ import com.instructure.canvas.espresso.waitForMatcherWithRefreshes import com.instructure.canvasapi2.models.DiscussionTopicHeader import com.instructure.espresso.OnViewWithId import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.click @@ -35,12 +36,10 @@ import com.instructure.espresso.page.onView import com.instructure.espresso.page.onViewWithText import com.instructure.espresso.page.plus import com.instructure.espresso.page.waitForView -import com.instructure.espresso.page.waitForViewWithId import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withDescendant import com.instructure.espresso.page.withId import com.instructure.espresso.page.withText -import com.instructure.espresso.replaceText import com.instructure.espresso.scrollTo import com.instructure.espresso.swipeDown import com.instructure.espresso.waitForCheck @@ -50,7 +49,7 @@ import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.anyOf import org.hamcrest.Matchers.containsString -class DiscussionListPage : BasePage(R.id.discussionListPage) { +class DiscussionListPage(val searchable: Searchable) : BasePage(R.id.discussionListPage) { private val createNewDiscussion by OnViewWithId(R.id.createNewDiscussion) private val announcementsRecyclerView by OnViewWithId(R.id.discussionRecyclerView) @@ -150,18 +149,6 @@ class DiscussionListPage : BasePage(R.id.discussionListPage) { onView(withContentDescription("Close")).click() } - fun clickOnSearchButton() { - onView(withId(R.id.search)).click() - } - - fun typeToSearchBar(textToType: String) { - waitForViewWithId(R.id.search_src_text).replaceText(textToType) - } - - fun clickOnClearSearchButton() { - onView(withId(R.id.search_close_btn)).click() - } - fun verifyExitWithoutSavingDialog() { onView(withText(R.string.exitWithoutSavingMessage)).check(matches(isDisplayed())) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt index a3c35dc8e9..7ebf099da4 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt @@ -28,6 +28,7 @@ import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.clearText @@ -39,7 +40,6 @@ import com.instructure.espresso.page.waitForView import com.instructure.espresso.page.waitForViewWithId import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withId -import com.instructure.espresso.replaceText import com.instructure.espresso.scrollTo import com.instructure.espresso.typeText import com.instructure.student.R @@ -47,7 +47,7 @@ import org.hamcrest.Matchers.allOf // Tests that files submitted for submissions, submission comments and discussions are // properly displayed. -class FileListPage : BasePage(R.id.fileListPage) { +class FileListPage(val searchable: Searchable) : BasePage(R.id.fileListPage) { private val addButton by OnViewWithId(R.id.addFab) private val uploadFileButton by OnViewWithId(R.id.addFileFab, autoAssert = false) @@ -124,19 +124,6 @@ class FileListPage : BasePage(R.id.fileListPage) { onView(allOf(withId(R.id.emptyView), isDisplayed())).assertDisplayed() } - fun clickSearchButton() { - onView(withId(R.id.search)).click() - } - - fun typeSearchInput(searchText: String) { - onView(withId(R.id.queryInput)).replaceText(searchText) - } - - fun clickResetSearchText() { - waitForView(withId(R.id.clearButton)).click() - onView(withId(R.id.backButton)).click() - } - fun assertSearchResultCount(expectedCount: Int) { Thread.sleep(2000) onView(withId(R.id.fileSearchRecyclerView) + withAncestor(R.id.container)).check( @@ -151,11 +138,7 @@ class FileListPage : BasePage(R.id.fileListPage) { ) } - fun pressSearchBackButton() { - onView(withId(R.id.backButton)).click() - } - fun assertFolderSize(folderName: String, expectedSize: Int) { - onView(allOf(withId(R.id.fileSize), hasSibling(withId(R.id.fileName) + withText(folderName)))).check(matches(containsTextCaseInsensitive("$expectedSize ${if (expectedSize == 1) "item" else "items"}"))) + waitForView(allOf(withId(R.id.fileSize), hasSibling(withId(R.id.fileName) + withText(folderName)))).check(matches(containsTextCaseInsensitive("$expectedSize ${if (expectedSize == 1) "item" else "items"}"))) } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt index b8f938bf65..71dea6a596 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt @@ -17,22 +17,28 @@ package com.instructure.student.ui.pages import android.view.View -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasSibling -import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvasapi2.models.Page import com.instructure.dataseeding.model.PageApiModel +import com.instructure.espresso.DoesNotExistAssertion +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.waitForCheck import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf -class PageListPage : BasePage(R.id.pageListPage) { +class PageListPage(val searchable: Searchable) : BasePage(R.id.pageListPage) { fun assertFrontPageDisplayed(page: PageApiModel) { val matcher = getFrontPageMatcher(page) @@ -96,9 +102,15 @@ class PageListPage : BasePage(R.id.pageListPage) { fun assertPageNotDisplayed(page: PageApiModel) { // Check for front page - onView(allOf(withId(R.id.homeSubLabel), withText(page.title))).check(doesNotExist()) + onView(allOf(withId(R.id.homeSubLabel), withText(page.title))).check(DoesNotExistAssertion(10000L)) // Check for regular page - onView(allOf(withId(R.id.title), withText(page.title))).check(doesNotExist()) + onView(allOf(withId(R.id.title), withText(page.title))).check(DoesNotExistAssertion(10000L)) + } + + fun assertPageListItemCount(expectedCount: Int) { + onView(allOf(withId(R.id.listView) + + ViewMatchers.withParent(withId(R.id.swipeRefreshLayout)) + + withAncestor(withId(R.id.pageListPage)))).waitForCheck(RecyclerViewItemCountAssertion(expectedCount)) } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt index ad59621f01..2285f6c0ce 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt @@ -34,6 +34,7 @@ import androidx.test.espresso.matcher.ViewMatchers import androidx.test.platform.app.InstrumentationRegistry import com.instructure.canvas.espresso.CanvasTest import com.instructure.espresso.InstructureActivityTestRule +import com.instructure.espresso.Searchable import com.instructure.espresso.swipeRight import com.instructure.pandautils.utils.Const import com.instructure.student.BuildConfig @@ -144,7 +145,7 @@ abstract class StudentTest : CanvasTest() { val annotationCommentListPage = AnnotationCommentListPage() val announcementListPage = AnnouncementListPage() val assignmentDetailsPage = AssignmentDetailsPage() - val assignmentListPage = AssignmentListPage() + val assignmentListPage = AssignmentListPage(Searchable(R.id.search, R.id.search_src_text)) val bookmarkPage = BookmarkPage() val calendarEventPage = CalendarEventPage() val canvasWebViewPage = CanvasWebViewPage() @@ -157,9 +158,9 @@ abstract class StudentTest : CanvasTest() { val dashboardPage = DashboardPage() val leftSideNavigationDrawerPage = LeftSideNavigationDrawerPage() val discussionDetailsPage = DiscussionDetailsPage() - val discussionListPage = DiscussionListPage() + val discussionListPage = DiscussionListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val editDashboardPage = EditDashboardPage() - val fileListPage = FileListPage() + val fileListPage = FileListPage(Searchable(R.id.search, R.id.queryInput, R.id.clearButton, R.id.backButton)) val fileUploadPage = FileUploadPage() val helpPage = HelpPage() val inboxConversationPage = InboxConversationPage() @@ -173,7 +174,7 @@ abstract class StudentTest : CanvasTest() { val modulesPage = ModulesPage() val newMessagePage = NewMessagePage() val notificationPage = NotificationPage() - val pageListPage = PageListPage() + val pageListPage = PageListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val pairObserverPage = PairObserverPage() val pandaAvatarPage = PandaAvatarPage() val peopleListPage = PeopleListPage() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AnnouncementsListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AnnouncementsListPageTest.kt index b53c9c5578..64934a2262 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AnnouncementsListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AnnouncementsListPageTest.kt @@ -35,8 +35,8 @@ import com.instructure.teacher.R import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest -import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.`is` import org.junit.Test @HiltAndroidTest @@ -76,9 +76,9 @@ class AnnouncementsListPageTest : TeacherTest() { val searchAnnouncement = announcements[2] announcementsListPage.assertAnnouncementCount(announcements.size + 1) // +1 to account for header - announcementsListPage.openSearch() - announcementsListPage.enterSearchQuery(searchAnnouncement.title!!.take(searchAnnouncement.title!!.length / 2)) - announcementsListPage.assertAnnouncementCount(2) // header + single search result + announcementsListPage.searchable.clickOnSearchButton() + announcementsListPage.searchable.typeToSearchBar(searchAnnouncement.title!!.take(searchAnnouncement.title!!.length / 2)) + announcementsListPage.assertSearchResultCount(1) announcementsListPage.assertHasAnnouncement(searchAnnouncement) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DiscussionsListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DiscussionsListPageTest.kt index 80f1b5803d..1f962bc1a0 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DiscussionsListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DiscussionsListPageTest.kt @@ -49,10 +49,10 @@ class DiscussionsListPageTest : TeacherTest() { fun searchesDiscussions() { val discussions = getToDiscussionsListPage(discussionCount = 3).courseDiscussionTopicHeaders[course.id]!! val searchDiscussion = discussions[2] - discussionsListPage.assertDiscussionCount(discussions.size + 1) // +1 to account for header - discussionsListPage.openSearch() - discussionsListPage.enterSearchQuery(searchDiscussion.title!!.take(searchDiscussion.title!!.length / 2)) - discussionsListPage.assertDiscussionCount(2) // header + single search result + discussionsListPage.assertDiscussionCount(discussions.size) + discussionsListPage.searchable.clickOnSearchButton() + discussionsListPage.searchable.typeToSearchBar(searchDiscussion.title!!.take(searchDiscussion.title!!.length / 2)) + discussionsListPage.assertDiscussionCount(1) discussionsListPage.assertHasDiscussion(searchDiscussion) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/PageListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/PageListPageTest.kt index 5492ddcc65..db39f7c554 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/PageListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/PageListPageTest.kt @@ -41,8 +41,8 @@ class PageListPageTest : TeacherTest() { val pages = getToPageListPage(pageCount = 3) val searchPage = pages[2] pageListPage.assertPageCount(pages.size) - pageListPage.openSearch() - pageListPage.enterSearchQuery(searchPage.title!!.take(searchPage.title!!.length / 2)) + pageListPage.searchable.clickOnSearchButton() + pageListPage.searchable.typeToSearchBar(searchPage.title!!.take(searchPage.title!!.length / 2)) pageListPage.assertPageCount(1) pageListPage.assertHasPage(searchPage) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizListPageTest.kt index 878a911118..f57ccc6c44 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizListPageTest.kt @@ -56,8 +56,8 @@ class QuizListPageTest : TeacherTest() { val quizzes = getToQuizzesPage(quizCount = 3) val searchQuiz = quizzes[2] quizListPage.assertQuizCount(quizzes.size + 1) // +1 to account for header - quizListPage.openSearch() - quizListPage.enterSearchQuery(searchQuiz.title!!.take(searchQuiz.title!!.length / 2)) + quizListPage.searchable.clickOnSearchButton() + quizListPage.searchable.typeToSearchBar(searchQuiz.title!!.take(searchQuiz.title!!.length / 2)) quizListPage.assertQuizCount(2) // header + single search result quizListPage.assertHasQuiz(searchQuiz) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt index 6f8268f398..fb91c72a4a 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt @@ -70,15 +70,15 @@ class AnnouncementsE2ETest : TeacherTest() { announcementsListPage.assertHasAnnouncement(announcement) Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${announcement2.title}', one of the announcements' name to the search input field.") - announcementsListPage.clickSearchButton() - announcementsListPage.typeSearchInput(announcement2.title) + announcementsListPage.searchable.clickOnSearchButton() + announcementsListPage.searchable.typeToSearchBar(announcement2.title) Log.d(STEP_TAG, "Assert that only 1 announcement matches for the search text, and it is '${announcement2.title}'.") announcementsListPage.assertSearchResultCount(1) announcementsListPage.assertHasAnnouncement(announcement2) Log.d(STEP_TAG, "Click on 'Reset' search (cross) icon and assert that all the announcements are displayed (2).") - announcementsListPage.clickResetSearchText() + announcementsListPage.searchable.clickOnClearSearchButton() announcementsListPage.assertSearchResultCount(2) Log.d(STEP_TAG,"Edit ${announcement.title} announcement's name to 'Haha'. Save the modifications.") diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt index 676ee37d3d..17132fd6d5 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt @@ -121,23 +121,28 @@ class DiscussionsE2ETest : TeacherTest() { Espresso.pressBack() Log.d(STEP_TAG,"Click on the Search icon and type some search query string which matches only with the previously created discussion's title.") - discussionsListPage.openSearch() - discussionsListPage.enterSearchQuery("Test Discussion") + discussionsListPage.searchable.clickOnSearchButton() + discussionsListPage.searchable.typeToSearchBar("Test Discussion") Log.d(STEP_TAG,"Assert that the '$newDiscussionTitle' discussion is displayed and it is the only one.") - discussionsListPage.assertDiscussionCount(2) // header + single search result + discussionsListPage.assertDiscussionCount(1) discussionsListPage.assertHasDiscussion(newDiscussionTitle) Espresso.pressBack() // need to press back to exit from the search input field Log.d(STEP_TAG,"Collapse the discussion list and assert that the '$newDiscussionTitle' discussion can NOT be seen.") discussionsListPage.toggleCollapseExpandIcon() - discussionsListPage.assertDiscussionCount(1) // header only + discussionsListPage.assertDiscussionCount(0) // header only discussionsListPage.assertDiscussionDoesNotExist(newDiscussionTitle) Log.d(STEP_TAG,"Expand the discussion list and assert that the '$newDiscussionTitle' discussion can be seen.") discussionsListPage.toggleCollapseExpandIcon() - discussionsListPage.assertDiscussionCount(2) // header only + single search result + discussionsListPage.assertDiscussionCount(1) discussionsListPage.assertHasDiscussion(newDiscussionTitle) + Log.d(STEP_TAG, "Click on the clear search input button (X) on the toolbar. Assert that the default state, so both of the discussions will be displayed.") + discussionsListPage.searchable.clickOnClearSearchButton() + discussionsListPage.assertHasDiscussion(discussion) + discussionsListPage.assertHasDiscussion(discussion2) + discussionsListPage.assertDiscussionCount(2) } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt index 0fa4c283b7..71aab3cde4 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt @@ -166,8 +166,8 @@ class FilesE2ETest: TeacherTest() { fileListPage.assertItemDisplayed("unfiled") Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${discussionAttachmentFile.name}', the file's name to the search input field.") - fileListPage.clickSearchButton() - fileListPage.typeSearchInput(discussionAttachmentFile.name) + fileListPage.searchable.clickOnSearchButton() + fileListPage.searchable.typeToSearchBar(discussionAttachmentFile.name) Log.d(STEP_TAG, "Assert that only 1 file matches for the search text, and it is '${discussionAttachmentFile.name}', and no directories has been shown in the result.") fileListPage.assertSearchResultCount(1) @@ -175,7 +175,7 @@ class FilesE2ETest: TeacherTest() { fileListPage.assertItemNotDisplayed("unfiled") Log.d(STEP_TAG, "Click on 'Reset' search (cross) icon and assert that all the root level directories and files are displayed (1).") - fileListPage.clickResetSearchText() + fileListPage.searchable.clickOnClearSearchButton() fileListPage.assertFileListCount(1) Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") @@ -210,12 +210,12 @@ class FilesE2ETest: TeacherTest() { fileListPage.assertItemDisplayed(newFolderName) Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${newFolderName}', the file's name to the search input field.") - fileListPage.clickSearchButton() - fileListPage.typeSearchInput(newFolderName) + fileListPage.searchable.clickOnSearchButton() + fileListPage.searchable.typeToSearchBar(newFolderName) Log.d(STEP_TAG,"Assert that empty view is displayed after deletion, because no folders will not be displayed in search result. Press back button (top one) to escape from Search 'view'.") fileListPage.assertViewEmpty() - fileListPage.pressSearchBackButton() + fileListPage.searchable.pressSearchBackButton() Log.d(STEP_TAG, "Select '$newFolderName' folder and delete it. Assert that it has been disappeared from the File List Page.") fileListPage.deleteFolder(newFolderName) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PagesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PagesE2ETest.kt index c54d283d5d..874ddd528b 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PagesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PagesE2ETest.kt @@ -147,12 +147,16 @@ class PagesE2ETest : TeacherTest() { pageListPage.assertPageIsPublished(newPageTitle) Log.d(STEP_TAG,"Click on the Search icon and type some search query string which matches only with the previously created page's title.") - pageListPage.openSearch() - pageListPage.enterSearchQuery("Test") + pageListPage.searchable.clickOnSearchButton() + pageListPage.searchable.typeToSearchBar("Test") Log.d(STEP_TAG,"Assert that the '$newPageTitle' titled page is displayed and it is the only one.") pageListPage.assertPageIsPublished(newPageTitle) pageListPage.assertPageCount(1) + + Log.d(STEP_TAG, "Click on the clear search input button (X) on the toolbar. Assert that the default state, so all the 4 pages will be displayed.") + pageListPage.searchable.clickOnClearSearchButton() + pageListPage.assertPageCount(4) } private fun createCoursePage( diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt index 08d0a0f2d5..5baed3c839 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt @@ -139,15 +139,15 @@ class PeopleE2ETest: TeacherTest() { Espresso.pressBack() Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${gradedStudent.name}', the graded student's name to the search input field.") - peopleListPage.clickSearchButton() - peopleListPage.typeSearchInput(gradedStudent.name) + peopleListPage.searchable.clickOnSearchButton() + peopleListPage.searchable.typeToSearchBar(gradedStudent.name) Log.d(STEP_TAG, "Assert that only 1 person matches for the search text, and it is '${gradedStudent.name}', the graded student.") peopleListPage.assertSearchResultCount(1) peopleListPage.assertPersonListed(gradedStudent) - Log.d(STEP_TAG, "Click on 'Reset' search (cross) icon and assert that all the poeple are displayed (5).") - peopleListPage.clickResetSearchText() + Log.d(STEP_TAG, "Click on 'Reset' search (X) icon and assert that all the poeple are displayed (5).") + peopleListPage.searchable.clickOnClearSearchButton() peopleListPage.assertSearchResultCount(5) Log.d(STEP_TAG, "Navigate back to Dashboard Page. Click on the Inbox bottom menu. Assert that the 'All' section is empty.") diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt index be005e6b5e..d8c9ad7b7f 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt @@ -17,14 +17,13 @@ package com.instructure.teacher.ui.pages import androidx.test.espresso.Espresso -import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.matcher.ViewMatchers import com.instructure.canvasapi2.models.DiscussionTopicHeader import com.instructure.dataseeding.model.DiscussionApiModel import com.instructure.espresso.OnViewWithId import com.instructure.espresso.RecyclerViewItemCountAssertion -import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage @@ -32,7 +31,6 @@ import com.instructure.espresso.page.onView import com.instructure.espresso.page.onViewWithContentDescription import com.instructure.espresso.page.onViewWithText import com.instructure.espresso.page.plus -import com.instructure.espresso.page.waitForView import com.instructure.espresso.page.waitForViewWithText import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withId @@ -48,13 +46,11 @@ import com.instructure.teacher.ui.utils.TypeInRCETextEditor * * @constructor Create empty Announcements list page */ -class AnnouncementsListPage : BasePage() { +class AnnouncementsListPage(val searchable: Searchable) : BasePage() { private val announcementListToolbar by OnViewWithId(R.id.discussionListToolbar) private val announcementsFAB by OnViewWithId(R.id.createNewDiscussion) private val announcementsRecyclerView by OnViewWithId(R.id.discussionRecyclerView) - private val searchButton by OnViewWithId(R.id.search) - private val searchInput by WaitForViewWithId(androidx.appcompat.R.id.search_src_text) private val createNewDiscussion by OnViewWithId(R.id.createNewDiscussion) /** @@ -110,23 +106,6 @@ class AnnouncementsListPage : BasePage() { announcementsFAB.assertDisplayed() } - /** - * Click on search button. - * - */ - fun openSearch() { - searchButton.click() - } - - /** - * Fill the search input field with the given query string. - * - * @param query: Query string parameter. - */ - fun enterSearchQuery(query: String) { - searchInput.perform(ViewActions.replaceText(query)) - } - /** * Assert that the announcements recyclerview count is equals to the given one. * @@ -206,31 +185,6 @@ class AnnouncementsListPage : BasePage() { onViewWithText(R.string.exitUnsaved).click() } - /** - * Click search button. - * - */ - fun clickSearchButton() { - onView(withId(R.id.search)).click() - } - - /** - * Type the given search text into the search input field. - * - * @param searchText: The search text query parameter. - */ - fun typeSearchInput(searchText: String) { - onView(withId(R.id.search_src_text)).replaceText(searchText.dropLast(1)) - } - - /** - * Click reset search text. - * - */ - fun clickResetSearchText() { - waitForView(withId(R.id.search_close_btn)).click() - } - /** * Assert search result count is equals to the expected. * diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsListPage.kt index 87394c2577..f6f8b9848c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsListPage.kt @@ -16,14 +16,13 @@ */ package com.instructure.teacher.ui.pages -import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.matcher.ViewMatchers import com.instructure.canvasapi2.models.DiscussionTopicHeader import com.instructure.dataseeding.model.DiscussionApiModel import com.instructure.espresso.OnViewWithId import com.instructure.espresso.RecyclerViewItemCountAssertion -import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage @@ -43,13 +42,11 @@ import com.instructure.teacher.R /** * Represents the Discussions List page. */ -class DiscussionsListPage : BasePage() { +class DiscussionsListPage(val searchable: Searchable) : BasePage() { private val discussionListToolbar by OnViewWithId(R.id.discussionListToolbar) private val discussionsFAB by OnViewWithId(R.id.createNewDiscussion) private val discussionsRecyclerView by OnViewWithId(R.id.discussionRecyclerView) - private val searchButton by OnViewWithId(R.id.search) - private val searchInput by WaitForViewWithId(androidx.appcompat.R.id.search_src_text) /** * Clicks on the specified [discussion] in the discussions list. @@ -112,29 +109,13 @@ class DiscussionsListPage : BasePage() { waitForViewWithText(discussion.title!!).assertDisplayed() } - /** - * Opens the search functionality in the discussions list. - */ - fun openSearch() { - searchButton.click() - } - - /** - * Enters the specified [query] into the search input field. - * - * @param query The search query to be entered. - */ - fun enterSearchQuery(query: String) { - searchInput.perform(ViewActions.replaceText(query)) - } - /** * Asserts the number of discussions in the discussions list. * * @param count The expected number of discussions. */ fun assertDiscussionCount(count: Int) { - discussionsRecyclerView.waitForCheck(RecyclerViewItemCountAssertion(count)) + discussionsRecyclerView.waitForCheck(RecyclerViewItemCountAssertion(count + 1)) //Because of the header. } /** diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/FileListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/FileListPage.kt index 49b03d3e4a..52634c0ee2 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/FileListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/FileListPage.kt @@ -28,6 +28,7 @@ import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed import com.instructure.espresso.clearText import com.instructure.espresso.click @@ -37,7 +38,6 @@ import com.instructure.espresso.page.plus import com.instructure.espresso.page.waitForView import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withId -import com.instructure.espresso.replaceText import com.instructure.espresso.scrollTo import com.instructure.espresso.typeText import com.instructure.teacher.R @@ -54,7 +54,7 @@ import org.hamcrest.Matchers.allOf * * @constructor Creates an instance of the FileListPage class. */ -class FileListPage : BasePage(R.id.fileListPage) { +class FileListPage(val searchable: Searchable) : BasePage(R.id.fileListPage) { private val addButton by OnViewWithId(R.id.addFab) private val uploadFileButton by OnViewWithId(R.id.addFileFab, autoAssert = false) @@ -169,30 +169,6 @@ class FileListPage : BasePage(R.id.fileListPage) { onView(allOf(isAssignableFrom(AppCompatButton::class.java), containsTextCaseInsensitive("DELETE"), isDisplayed())).click() } - /** - * Clicks the search button. - */ - fun clickSearchButton() { - onView(withId(R.id.search)).click() - } - - /** - * Types the specified search text into the search input field. - * - * @param searchText The text to be typed in the search input field. - */ - fun typeSearchInput(searchText: String) { - onView(withId(R.id.queryInput)).replaceText(searchText) - } - - /** - * Clicks the reset search text button. - */ - fun clickResetSearchText() { - waitForView(withId(R.id.clearButton)).click() - onView(withId(R.id.backButton)).click() - } - /** * Asserts the count of search results matches the expected count. * @@ -216,11 +192,4 @@ class FileListPage : BasePage(R.id.fileListPage) { ViewAssertions.matches(hasChildCount(expectedCount)) ) } - - /** - * Presses the back button in the search view. - */ - fun pressSearchBackButton() { - onView(withId(R.id.backButton)).click() - } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PageListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PageListPage.kt index 6704501117..a42d4c9639 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PageListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PageListPage.kt @@ -17,14 +17,13 @@ package com.instructure.teacher.ui.pages import android.view.View import androidx.test.espresso.Espresso -import androidx.test.espresso.action.ViewActions import androidx.test.espresso.matcher.ViewMatchers.hasSibling import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvasapi2.models.Page import com.instructure.espresso.OnViewWithId import com.instructure.espresso.RecyclerViewItemCountAssertion -import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage @@ -44,11 +43,7 @@ import org.hamcrest.Matchers.containsString * This page extends the BasePage class and provides functionality for interacting with the elements on the "Page List" page. * It contains methods for clicking on the create new page button, opening a page, performing a search, and asserting various page-related conditions. */ -class PageListPage : BasePage() { - - private val searchButton by OnViewWithId(R.id.search) - - private val searchInput by WaitForViewWithId(androidx.appcompat.R.id.search_src_text) +class PageListPage(val searchable: Searchable) : BasePage() { private val pageRecyclerView by OnViewWithId(R.id.pageRecyclerView) @@ -81,22 +76,6 @@ class PageListPage : BasePage() { onView(matcher).click() } - /** - * Opens the search bar. - */ - fun openSearch() { - searchButton.click() - } - - /** - * Enters the search query in the search bar. - * - * @param query The search query to be entered. - */ - fun enterSearchQuery(query: String) { - searchInput.perform(ViewActions.replaceText(query)) - } - /** * Asserts the number of pages in the page list. * diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PeopleListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PeopleListPage.kt index 009486633f..a9458e6012 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PeopleListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/PeopleListPage.kt @@ -23,6 +23,7 @@ import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasChildCount import androidx.test.espresso.matcher.ViewMatchers.hasSibling import com.instructure.dataseeding.model.CanvasUserApiModel +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage @@ -33,7 +34,6 @@ import com.instructure.espresso.page.waitForViewWithText import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withId import com.instructure.espresso.page.withText -import com.instructure.espresso.replaceText import com.instructure.teacher.R import org.hamcrest.Matcher import org.hamcrest.Matchers @@ -45,7 +45,7 @@ import org.hamcrest.Matchers * It contains methods for clicking on a person, asserting the presence of a person in the list with optional role filtering, asserting the search result count, * scrolling to a specific person, performing search actions, and asserting the visibility of the empty view and person role. */ -class PeopleListPage : BasePage(R.id.peopleListPage) { +class PeopleListPage(val searchable: Searchable) : BasePage(R.id.peopleListPage) { /** * Clicks on a person in the list. @@ -113,29 +113,6 @@ class PeopleListPage : BasePage(R.id.peopleListPage) { ) } - /** - * Clicks the search button. - */ - fun clickSearchButton() { - onView(withId(R.id.search)).click() - } - - /** - * Enters the search text. - * - * @param searchText The text to enter in the search input. - */ - fun typeSearchInput(searchText: String) { - onView(withId(R.id.search_src_text)).replaceText(searchText) - } - - /** - * Clicks the reset search text button. - */ - fun clickResetSearchText() { - waitForView(withId(R.id.search_close_btn)).click() - } - /** * Asserts that the empty view is displayed. */ diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizListPage.kt index 1e32f11b1c..1068d36295 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizListPage.kt @@ -16,10 +16,10 @@ */ package com.instructure.teacher.ui.pages -import androidx.test.espresso.action.ViewActions import com.instructure.canvasapi2.models.Quiz import com.instructure.espresso.OnViewWithId import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.Searchable import com.instructure.espresso.WaitForViewWithId import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click @@ -39,7 +39,7 @@ import com.instructure.teacher.R * Additionally, it provides methods for asserting the display of the "No Quizzes" view, checking the presence of a quiz, clicking on a quiz, opening the search bar, entering a search query, * asserting the quiz count, and refreshing the page. */ -class QuizListPage : BasePage() { +class QuizListPage(val searchable: Searchable) : BasePage() { /** * The quiz list toolbar view on the page. @@ -100,22 +100,6 @@ class QuizListPage : BasePage() { waitForViewWithText(quizTitle).click() } - /** - * Opens the search bar. - */ - fun openSearch() { - searchButton.click() - } - - /** - * Enters a search query in the search input. - * - * @param query The search query to be entered. - */ - fun enterSearchQuery(query: String) { - searchInput.perform(ViewActions.replaceText(query)) - } - /** * Asserts the count of quizzes on the page. * diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt index d80b8e4783..7dafc5a3ab 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt @@ -25,7 +25,9 @@ import androidx.test.espresso.ViewAction import androidx.test.espresso.matcher.ViewMatchers import com.instructure.canvas.espresso.CanvasTest import com.instructure.espresso.InstructureActivityTestRule +import com.instructure.espresso.Searchable import com.instructure.teacher.BuildConfig +import com.instructure.teacher.R import com.instructure.teacher.activities.LoginActivity import com.instructure.teacher.ui.espresso.TeacherHiltTestApplication_Application import com.instructure.teacher.ui.pages.AboutPage @@ -121,7 +123,7 @@ abstract class TeacherTest : CanvasTest() { * Required for auto complete of page objects within tests */ val addMessagePage = AddMessagePage() - val announcementsListPage = AnnouncementsListPage() + val announcementsListPage = AnnouncementsListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val assigneeListPage = AssigneeListPage() val assignmentDetailsPage = AssignmentDetailsPage() val assignmentDueDatesPage = AssignmentDueDatesPage() @@ -144,7 +146,7 @@ abstract class TeacherTest : CanvasTest() { val profileSettingsPage = ProfileSettingsPage() val editProfileSettingsPage = EditProfileSettingsPage() val discussionsDetailsPage = DiscussionsDetailsPage() - val discussionsListPage = DiscussionsListPage() + val discussionsListPage = DiscussionsListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val editAnnouncementPage = EditAnnouncementPage() val editAssignmentDetailsPage = EditAssignmentDetailsPage() val editDiscussionsDetailsPage = EditDiscussionsDetailsPage() @@ -159,10 +161,10 @@ abstract class TeacherTest : CanvasTest() { val modulesPage = ModulesPage() val navDrawerPage = NavDrawerPage() val notATeacherPage = NotATeacherPage() - val pageListPage = PageListPage() - val peopleListPage = PeopleListPage() + val pageListPage = PageListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) + val peopleListPage = PeopleListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val quizDetailsPage = QuizDetailsPage() - val quizListPage = QuizListPage() + val quizListPage = QuizListPage(Searchable(R.id.search, R.id.search_src_text, R.id.clearButton, R.id.backButton)) val quizSubmissionListPage = QuizSubmissionListPage() val speedGraderCommentsPage = SpeedGraderCommentsPage() val speedGraderFilesPage = SpeedGraderFilesPage() @@ -174,7 +176,7 @@ abstract class TeacherTest : CanvasTest() { val syllabusPage = SyllabusPage() val todoPage = TodoPage() val webViewLoginPage = WebViewLoginPage() - val fileListPage = FileListPage() + val fileListPage = FileListPage(Searchable(R.id.search, R.id.queryInput, R.id.clearButton, R.id.backButton)) } diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt index a6248e7e21..a9c27b5bc9 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt @@ -23,9 +23,11 @@ import androidx.annotation.IdRes import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.ViewAssertion +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.matcher.ViewMatchers import androidx.viewpager.widget.ViewPager import com.google.android.material.bottomnavigation.BottomNavigationView +import junit.framework.AssertionFailedError import org.hamcrest.Matchers import org.junit.Assert.assertEquals @@ -74,3 +76,21 @@ class NotificationBadgeAssertion(@IdRes private val menuItemId: Int, private val assertEquals(badgeCount, expectedCount) } } + +class DoesNotExistAssertion(private val timeout: Long, private val pollInterval: Long = 500L) : ViewAssertion { + override fun check(view: View?, noViewFoundException: NoMatchingViewException?) { + var elapsedTime = 0L + + while (elapsedTime < timeout) { + try { + doesNotExist() + return + } catch (e: AssertionFailedError) { + Thread.sleep(pollInterval) + elapsedTime += pollInterval + } + } + + throw AssertionError("View still exists after $timeout milliseconds.") + } +} \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/Searchable.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/Searchable.kt new file mode 100644 index 0000000000..b8d5902141 --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/Searchable.kt @@ -0,0 +1,25 @@ +package com.instructure.espresso + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.instructure.espresso.matchers.WaitForViewMatcher.waitForView + +class Searchable(private val searchButtonId: Int? = null, private val queryInputId: Int? = null, private val clearButtonId: Int? = null, private val backButtonId: Int? = null) { + + fun clickOnSearchButton() { + onView(searchButtonId?.let { withId(it) }).click() + } + + fun typeToSearchBar(textToType: String) { + onView(queryInputId?.let { withId(it) }).perform(ViewActions.replaceText(textToType)) + } + + fun clickOnClearSearchButton() { + waitForView(clearButtonId?.let { withId(it) }).click() + } + + fun pressSearchBackButton() { + onView(backButtonId?.let { withId(it) }).click() + } +} \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt index c31c9a5dd9..0d543a6b25 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt @@ -41,9 +41,9 @@ object WaitForViewMatcher { // https://github.com/braintree/braintree_android/blob/25513d76da88fe2ce9f476c4dc51f24cf6e26104/TestUtils/src/main/java/com/braintreepayments/testutils/ui/ViewHelper.java#L30 // The viewMatcher is called on every view to determine what matches. Must be fast! - fun waitForView(viewMatcher: Matcher, duration: Long = 10): ViewInteraction { + fun waitForView(viewMatcher: Matcher?, duration: Long = 10): ViewInteraction { log.i("Wait for View to be visible.") - return waitForViewWithCustomMatcher(viewMatcher, duration, withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)) + return waitForViewWithCustomMatcher(viewMatcher!!, duration, withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)) } fun waitForViewToBeClickable(viewMatcher: Matcher, duration: Long = 10): ViewInteraction { From eb0592658766c61e86541d526004afb666873e6a Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Tue, 15 Aug 2023 15:06:50 +0200 Subject: [PATCH 34/61] Updated version --- apps/student/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/student/build.gradle b/apps/student/build.gradle index 43afe3bf59..bc4c0c3bec 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -50,8 +50,8 @@ android { applicationId "com.instructure.candroid" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 253 - versionName = '6.25.1' + versionCode = 254 + versionName = '6.26.0' vectorDrawables.useSupportLibrary = true multiDexEnabled = true From 61167ca85ce0e8133c822b86c742f70efda61bbb Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Wed, 16 Aug 2023 10:03:32 +0200 Subject: [PATCH 35/61] Landscape test fixes. --- .../student/ui/pages/AssignmentListPage.kt | 4 +++- .../com/instructure/student/ui/pages/ModulesPage.kt | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt index 6244425c93..bb5a6ef6b5 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt @@ -93,7 +93,9 @@ class AssignmentListPage(val searchable: Searchable) : BasePage(pageResId = R.id } fun assertAssignmentDisplayedWithGrade(assignmentName: String, gradeString: String) { - onView(withId(R.id.title) + withParent(R.id.textContainer) + withText(assignmentName)).assertDisplayed() + val matcher = withId(R.id.title) + withParent(R.id.textContainer) + withText(assignmentName) + scrollRecyclerView(R.id.listView, matcher) + onView(matcher).assertDisplayed() val pointsMatcher = withId(R.id.title) + withText(assignmentName) onView(withId(R.id.points) + withParent(hasSibling(pointsMatcher))).assertHasText(gradeString) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt index b6e915e336..0dc2cd51cd 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt @@ -19,6 +19,7 @@ package com.instructure.student.ui.pages import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.PerformException import androidx.test.espresso.action.ViewActions.swipeDown +import androidx.test.espresso.action.ViewActions.swipeUp import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.* @@ -87,11 +88,17 @@ class ModulesPage : BasePage(R.id.modulesPage) { } fun assertPossiblePointsDisplayed(points: String) { - onView(withId(R.id.points) + withText("$points pts")).assertDisplayed() + val matcher = withId(R.id.points) + withText("$points pts") + + scrollRecyclerView(R.id.listView, matcher) + onView(matcher).assertDisplayed() } fun assertPossiblePointsNotDisplayed(name: String) { - onView(withParent(hasSibling(withChild(withId(R.id.title) + withText(name)))) + withId(R.id.points)).assertNotDisplayed() + val matcher = withParent(hasSibling(withChild(withId(R.id.title) + withText(name)))) + withId(R.id.points) + + scrollRecyclerView(R.id.listView, matcher) + onView(matcher).assertNotDisplayed() } /** From 155fab52499cbc9e440d61f178373239e361c4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Wed, 16 Aug 2023 16:23:06 +0200 Subject: [PATCH 36/61] fix foreground notifications --- .../file/download/FileDownloadWorker.kt | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/file/download/FileDownloadWorker.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/file/download/FileDownloadWorker.kt index f8fb695dd3..954d39fdd5 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/file/download/FileDownloadWorker.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/file/download/FileDownloadWorker.kt @@ -51,11 +51,13 @@ class FileDownloadWorker @AssistedInject constructor( private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - override suspend fun doWork(): Result { - val fileName = inputData.getString(INPUT_FILE_NAME) ?: "" - val fileUrl = inputData.getString(INPUT_FILE_URL) ?: "" - val notificationId = Random.nextInt() + private val fileName = inputData.getString(INPUT_FILE_NAME) ?: "" + private val fileUrl = inputData.getString(INPUT_FILE_URL) ?: "" + private val notificationId = Random.nextInt() + + private var foregroundInfo: ForegroundInfo = createForegroundInfo(notificationId, fileName, 0) + override suspend fun doWork(): Result { registerNotificationChannel(context) val downloadFileName = createDownloadFileName(fileName) @@ -63,14 +65,21 @@ class FileDownloadWorker @AssistedInject constructor( val downloadedFile = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), downloadFileName) - setForeground(createForegroundInfo(notificationId, fileName, 0)) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + setForeground(foregroundInfo) + } var result = Result.retry() fileDownloadApi.downloadFile(fileUrl).saveFile(downloadedFile) .collect { downloadState -> when (downloadState) { is DownloadState.InProgress -> { - setForeground(createForegroundInfo(notificationId, fileName, downloadState.progress)) + foregroundInfo = createForegroundInfo(notificationId, fileName, downloadState.progress) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + setForeground(foregroundInfo) + } else { + updateForegroundNotification() + } } is DownloadState.Failure -> { @@ -138,8 +147,6 @@ class FileDownloadWorker @AssistedInject constructor( val notification = NotificationCompat.Builder(applicationContext, FileUploadWorker.CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification_canvas_logo) - .setProgress(100, 100, false) - .setOngoing(false) .setContentTitle(context.getString(R.string.downloadSuccessful)) .setContentText(fileName) .setContentIntent(pendingIntent) @@ -150,14 +157,20 @@ class FileDownloadWorker @AssistedInject constructor( private fun updateNotificationFailed(notificationId: Int, fileName: String) { val notification = NotificationCompat.Builder(applicationContext, FileUploadWorker.CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification_canvas_logo) - .setProgress(100, 100, false) - .setOngoing(false) .setContentTitle(context.getString(R.string.downloadFailed)) .setContentText(fileName) .build() notificationManager.notify(notificationId + 1, notification) } + override suspend fun getForegroundInfo(): ForegroundInfo { + return foregroundInfo + } + + private fun updateForegroundNotification() { + notificationManager.notify(notificationId, foregroundInfo.notification) + } + companion object { const val INPUT_FILE_NAME = "fileName" const val INPUT_FILE_URL = "fileUrl" From cd575704fa1d6c2a1188574612ef3a052fb03880 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Sat, 19 Aug 2023 20:38:31 +0200 Subject: [PATCH 37/61] [MBL-16737][Student] - Implement not favorited group interaction test on dashboard (#2105) --- .../interaction/GroupLinksInteractionTest.kt | 37 ++++++++++++++++++- .../student/ui/pages/DashboardPage.kt | 9 +++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt index 79c3f4a137..aa1c84ccdf 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt @@ -25,6 +25,7 @@ import com.instructure.canvas.espresso.mockCanvas.addFolderToCourse import com.instructure.canvas.espresso.mockCanvas.addGroupToCourse import com.instructure.canvas.espresso.mockCanvas.addPageToCourse import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvas.espresso.refresh import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.DiscussionTopicHeader import com.instructure.canvasapi2.models.Group @@ -72,6 +73,38 @@ class GroupLinksInteractionTest : StudentTest() { dashboardPage.assertDisplaysGroup(group, course) } + // Test not favorite group on dashboard + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GROUPS, TestCategory.INTERACTION, false, SecondaryFeatureCategory.GROUPS_DASHBOARD) + fun testGroupLink_dashboard_favoriteLogics() { + val data = setUpGroupAndSignIn() + val user = data.users.values.first() + val nonFavoriteGroup = data.addGroupToCourse( + course = course, + members = listOf(user), + isFavorite = false + ) + refresh() //Need to refresh because when we navigated to Dashboard page the nonFavoriteGroup was not existed yet. (However it won't be displayed because it's not favorite) + dashboardPage.assertGroupNotDisplayed(nonFavoriteGroup) + dashboardPage.assertDisplaysGroup(group, course) + } + + // Test that if no groups has selected as favorite then we display all groups + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GROUPS, TestCategory.INTERACTION, false, SecondaryFeatureCategory.GROUPS_DASHBOARD) + fun testGroupLink_dashboard_not_selected_displays_all() { + val data = setUpGroupAndSignIn(isFavorite = false) + val user = data.users.values.first() + val group2 = data.addGroupToCourse( + course = course, + members = listOf(user), + isFavorite = false + ) + refresh() //Need to refresh because when we navigated to Dashboard page the group2 was not existed yet. + dashboardPage.assertDisplaysGroup(group, course) + dashboardPage.assertDisplaysGroup(group2, course) + } + // Link to file preview opens file - eg: "/groups/:id/files/folder/:id?preview=:id" @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.GROUPS, TestCategory.INTERACTION, false, SecondaryFeatureCategory.GROUPS_FILES) @@ -196,7 +229,7 @@ class GroupLinksInteractionTest : StudentTest() { // Mock a single student and course, mock a group and a number of items associated with the group, // sign in, then navigate to the dashboard. - private fun setUpGroupAndSignIn(): MockCanvas { + private fun setUpGroupAndSignIn(isFavorite: Boolean = true): MockCanvas { // Basic info val data = MockCanvas.init( @@ -211,7 +244,7 @@ class GroupLinksInteractionTest : StudentTest() { group = data.addGroupToCourse( course = course, members = listOf(user), - isFavorite = true + isFavorite = isFavorite ) // Add a discussion diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt index 229d1253ee..caa43663b0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt @@ -279,6 +279,15 @@ class DashboardPage : BasePage(R.id.dashboardPage) { onView(matcher).check(doesNotExist()) } + fun assertGroupNotDisplayed(group: Group) { + val matcher = allOf( + withText(group.name), + withId(R.id.titleTextView), + withAncestor(R.id.swipeRefreshLayout) + ) + onView(matcher).check(doesNotExist()) + } + fun changeCourseNickname(changeTo: String) { onView(withId(R.id.newCourseNickname)).replaceText(changeTo) onView(withText(android.R.string.ok) + withAncestor(R.id.buttonPanel)).click() From 02930945abface39174a3a16bf9dd4cd790ac051 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Tue, 22 Aug 2023 09:52:12 +0200 Subject: [PATCH 38/61] [MBL-16983][Student][Teacher] Login help text change #2112 refs: MBL-16983 affects: Teacher, Student release note: none --- libs/login-api-2/src/main/res/layout/adapter_account_footer.xml | 2 +- libs/login-api-2/src/main/res/values-ar/strings.xml | 1 - .../login-api-2/src/main/res/values-b+da+DK+instk12/strings.xml | 1 - .../login-api-2/src/main/res/values-b+en+AU+unimelb/strings.xml | 1 - .../src/main/res/values-b+en+GB+instukhe/strings.xml | 1 - .../login-api-2/src/main/res/values-b+nb+NO+instk12/strings.xml | 1 - .../login-api-2/src/main/res/values-b+sv+SE+instk12/strings.xml | 1 - libs/login-api-2/src/main/res/values-b+zh+HK/strings.xml | 1 - libs/login-api-2/src/main/res/values-b+zh+Hans/strings.xml | 1 - libs/login-api-2/src/main/res/values-b+zh+Hant/strings.xml | 1 - libs/login-api-2/src/main/res/values-ca/strings.xml | 1 - libs/login-api-2/src/main/res/values-cy/strings.xml | 1 - libs/login-api-2/src/main/res/values-da/strings.xml | 1 - libs/login-api-2/src/main/res/values-de/strings.xml | 1 - libs/login-api-2/src/main/res/values-en-rAU/strings.xml | 1 - libs/login-api-2/src/main/res/values-en-rCA/strings.xml | 1 - libs/login-api-2/src/main/res/values-en-rCY/strings.xml | 1 - libs/login-api-2/src/main/res/values-en-rGB/strings.xml | 1 - libs/login-api-2/src/main/res/values-es-rES/strings.xml | 1 - libs/login-api-2/src/main/res/values-es/strings.xml | 1 - libs/login-api-2/src/main/res/values-fi/strings.xml | 1 - libs/login-api-2/src/main/res/values-fr-rCA/strings.xml | 1 - libs/login-api-2/src/main/res/values-fr/strings.xml | 1 - libs/login-api-2/src/main/res/values-ht/strings.xml | 1 - libs/login-api-2/src/main/res/values-is/strings.xml | 1 - libs/login-api-2/src/main/res/values-it/strings.xml | 1 - libs/login-api-2/src/main/res/values-ja/strings.xml | 1 - libs/login-api-2/src/main/res/values-mi/strings.xml | 1 - libs/login-api-2/src/main/res/values-ms/strings.xml | 1 - libs/login-api-2/src/main/res/values-nb/strings.xml | 1 - libs/login-api-2/src/main/res/values-nl/strings.xml | 1 - libs/login-api-2/src/main/res/values-pl/strings.xml | 1 - libs/login-api-2/src/main/res/values-pt-rBR/strings.xml | 1 - libs/login-api-2/src/main/res/values-pt-rPT/strings.xml | 1 - libs/login-api-2/src/main/res/values-ru/strings.xml | 1 - libs/login-api-2/src/main/res/values-sl/strings.xml | 1 - libs/login-api-2/src/main/res/values-sv/strings.xml | 1 - libs/login-api-2/src/main/res/values-th/strings.xml | 1 - libs/login-api-2/src/main/res/values-vi/strings.xml | 1 - libs/login-api-2/src/main/res/values-zh/strings.xml | 1 - libs/login-api-2/src/main/res/values/strings.xml | 2 +- 41 files changed, 2 insertions(+), 41 deletions(-) diff --git a/libs/login-api-2/src/main/res/layout/adapter_account_footer.xml b/libs/login-api-2/src/main/res/layout/adapter_account_footer.xml index d42dbae34d..380e298d7c 100644 --- a/libs/login-api-2/src/main/res/layout/adapter_account_footer.xml +++ b/libs/login-api-2/src/main/res/layout/adapter_account_footer.xml @@ -44,6 +44,6 @@ android:textColor="@color/textInfo" android:fontFamily="@font/lato_font_family" android:textSize="16sp" - android:text="@string/accountDomainFooterLink"/> + android:text="@string/accountDomainFooterLoginHelp"/> \ No newline at end of file diff --git a/libs/login-api-2/src/main/res/values-ar/strings.xml b/libs/login-api-2/src/main/res/values-ar/strings.xml index a6dc6fea19..23551097f6 100644 --- a/libs/login-api-2/src/main/res/values-ar/strings.xml +++ b/libs/login-api-2/src/main/res/values-ar/strings.xml @@ -109,7 +109,6 @@ school.instructure.com حذف المستخدم السابق ألا تستطيع العثور على مدرستك؟ حاول كتابة رابط URL الكامل للمدرسة. - اضغط هنا للحصول على المساعدة. لا يوجد اتصال بالإنترنت يتطلب هذا الإجراء اتصالاً بالإنترنت. diff --git a/libs/login-api-2/src/main/res/values-b+da+DK+instk12/strings.xml b/libs/login-api-2/src/main/res/values-b+da+DK+instk12/strings.xml index c14f873ae4..2f2c627f22 100644 --- a/libs/login-api-2/src/main/res/values-b+da+DK+instk12/strings.xml +++ b/libs/login-api-2/src/main/res/values-b+da+DK+instk12/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Fjern forrige bruger Kan du ikke finde din skole? Prøv at indtaste skolens fulde URL. - Tryk her for at få hjælp. Ingen internetforbindelse Denne handling kræver en internetforbindelse. diff --git a/libs/login-api-2/src/main/res/values-b+en+AU+unimelb/strings.xml b/libs/login-api-2/src/main/res/values-b+en+AU+unimelb/strings.xml index 562a29b642..de85b5f2a0 100644 --- a/libs/login-api-2/src/main/res/values-b+en+AU+unimelb/strings.xml +++ b/libs/login-api-2/src/main/res/values-b+en+AU+unimelb/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Remove Previous User Can\'t find your school? Try typing the full school URL. - Tap here for help. No Internet Connection This action requires an internet connection. diff --git a/libs/login-api-2/src/main/res/values-b+en+GB+instukhe/strings.xml b/libs/login-api-2/src/main/res/values-b+en+GB+instukhe/strings.xml index 2008cc1293..9ad8745705 100644 --- a/libs/login-api-2/src/main/res/values-b+en+GB+instukhe/strings.xml +++ b/libs/login-api-2/src/main/res/values-b+en+GB+instukhe/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Remove Previous User Can\'t find your school? Try typing the full school URL. - Tap here for help. No Internet Connection This action requires an internet connection. diff --git a/libs/login-api-2/src/main/res/values-b+nb+NO+instk12/strings.xml b/libs/login-api-2/src/main/res/values-b+nb+NO+instk12/strings.xml index ec5a6c20af..527ce5beb1 100644 --- a/libs/login-api-2/src/main/res/values-b+nb+NO+instk12/strings.xml +++ b/libs/login-api-2/src/main/res/values-b+nb+NO+instk12/strings.xml @@ -110,7 +110,6 @@ school.instructure.com Fjerne forrige bruker Finner du ikke skolen din? Prøv å skrive hele skolens URL. - Trykk her for hjelp. Ingen Internett-tilkobling Denne handlingen krever Internett-tilkobling. diff --git a/libs/login-api-2/src/main/res/values-b+sv+SE+instk12/strings.xml b/libs/login-api-2/src/main/res/values-b+sv+SE+instk12/strings.xml index 6545349e99..cd28a78c01 100644 --- a/libs/login-api-2/src/main/res/values-b+sv+SE+instk12/strings.xml +++ b/libs/login-api-2/src/main/res/values-b+sv+SE+instk12/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Ta bort tidigare användare Kan du inte hitta din skola? Försök att skriva in skolans fullständiga URL. - Tryck här för hjälp. Ingen internetanslutning Den här åtgärden kräver internetanslutning. diff --git a/libs/login-api-2/src/main/res/values-b+zh+HK/strings.xml b/libs/login-api-2/src/main/res/values-b+zh+HK/strings.xml index 7617208253..438733e270 100644 --- a/libs/login-api-2/src/main/res/values-b+zh+HK/strings.xml +++ b/libs/login-api-2/src/main/res/values-b+zh+HK/strings.xml @@ -109,7 +109,6 @@ school.instructure.com 移除先前使用者 找不到您的學校?請嘗試輸入學校完整URL。 - 點擊此處獲取支援。 沒有網絡連線 此動作需要網絡連線。 diff --git a/libs/login-api-2/src/main/res/values-b+zh+Hans/strings.xml b/libs/login-api-2/src/main/res/values-b+zh+Hans/strings.xml index 871b422f6d..72d0ea9c83 100644 --- a/libs/login-api-2/src/main/res/values-b+zh+Hans/strings.xml +++ b/libs/login-api-2/src/main/res/values-b+zh+Hans/strings.xml @@ -109,7 +109,6 @@ school.instructure.com 删除上一个用户 找不到您的学校?尝试键入完整的学校 URL。 - 轻击此处获取帮助。 无互联网连接 此操作需要互联网连接。 diff --git a/libs/login-api-2/src/main/res/values-b+zh+Hant/strings.xml b/libs/login-api-2/src/main/res/values-b+zh+Hant/strings.xml index 7617208253..438733e270 100644 --- a/libs/login-api-2/src/main/res/values-b+zh+Hant/strings.xml +++ b/libs/login-api-2/src/main/res/values-b+zh+Hant/strings.xml @@ -109,7 +109,6 @@ school.instructure.com 移除先前使用者 找不到您的學校?請嘗試輸入學校完整URL。 - 點擊此處獲取支援。 沒有網絡連線 此動作需要網絡連線。 diff --git a/libs/login-api-2/src/main/res/values-ca/strings.xml b/libs/login-api-2/src/main/res/values-ca/strings.xml index 36169a8672..6bbb991bd4 100644 --- a/libs/login-api-2/src/main/res/values-ca/strings.xml +++ b/libs/login-api-2/src/main/res/values-ca/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Eliminar l\'usuari anterior No trobeu la vostra escola? Proveu d\'escriure l\'URL de l\'escola complet. - Toqueu aquí per obtenir ajuda. No hi ha cap connexió a Internet Per dur a terme aquesta acció cal tenir una connexió a Internet. diff --git a/libs/login-api-2/src/main/res/values-cy/strings.xml b/libs/login-api-2/src/main/res/values-cy/strings.xml index 0e1d15dd39..11cfe6cf52 100644 --- a/libs/login-api-2/src/main/res/values-cy/strings.xml +++ b/libs/login-api-2/src/main/res/values-cy/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Tynnu defnyddiwr blaenorol? Methu dod o hyd i’ch ysgol? Ceisiwch deipio URL llawn yr ysgol. - Tapiwch yma i gael help. Dim cysylltiad â\'r rhyngrwyd Mae angen cysylltiad â\'r rhyngrwyd i wneud hyn. diff --git a/libs/login-api-2/src/main/res/values-da/strings.xml b/libs/login-api-2/src/main/res/values-da/strings.xml index 1faf265adb..8f1838288b 100644 --- a/libs/login-api-2/src/main/res/values-da/strings.xml +++ b/libs/login-api-2/src/main/res/values-da/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Fjern forrige bruger Kan du ikke finde din skole? Prøv at indtaste skolens fulde URL. - Tryk her for at få hjælp. Ingen internetforbindelse Denne handling kræver en internetforbindelse. diff --git a/libs/login-api-2/src/main/res/values-de/strings.xml b/libs/login-api-2/src/main/res/values-de/strings.xml index 8389e442df..47261dfc0b 100644 --- a/libs/login-api-2/src/main/res/values-de/strings.xml +++ b/libs/login-api-2/src/main/res/values-de/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Vorherige/n Benutzer*in entfernen Finden Sie Ihre Schule nicht? Geben Sie den vollständigen URL der Schule ein. - Tippen Sie hier, um Hilfe zu erhalten. Keine Internetverbindung Diese Aktion erfordert eine Internetverbindung. diff --git a/libs/login-api-2/src/main/res/values-en-rAU/strings.xml b/libs/login-api-2/src/main/res/values-en-rAU/strings.xml index 562a29b642..de85b5f2a0 100644 --- a/libs/login-api-2/src/main/res/values-en-rAU/strings.xml +++ b/libs/login-api-2/src/main/res/values-en-rAU/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Remove Previous User Can\'t find your school? Try typing the full school URL. - Tap here for help. No Internet Connection This action requires an internet connection. diff --git a/libs/login-api-2/src/main/res/values-en-rCA/strings.xml b/libs/login-api-2/src/main/res/values-en-rCA/strings.xml index d319e90b16..3727f20dd5 100644 --- a/libs/login-api-2/src/main/res/values-en-rCA/strings.xml +++ b/libs/login-api-2/src/main/res/values-en-rCA/strings.xml @@ -112,7 +112,6 @@ F school.instructure.com Remove Previous User Can\'t find your school? Try typing the full school URL. - Tap here for help. No Internet Connection This action requires an internet connection. diff --git a/libs/login-api-2/src/main/res/values-en-rCY/strings.xml b/libs/login-api-2/src/main/res/values-en-rCY/strings.xml index 2008cc1293..9ad8745705 100644 --- a/libs/login-api-2/src/main/res/values-en-rCY/strings.xml +++ b/libs/login-api-2/src/main/res/values-en-rCY/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Remove Previous User Can\'t find your school? Try typing the full school URL. - Tap here for help. No Internet Connection This action requires an internet connection. diff --git a/libs/login-api-2/src/main/res/values-en-rGB/strings.xml b/libs/login-api-2/src/main/res/values-en-rGB/strings.xml index 4efa16bd21..4b3cdd4bcb 100644 --- a/libs/login-api-2/src/main/res/values-en-rGB/strings.xml +++ b/libs/login-api-2/src/main/res/values-en-rGB/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Remove Previous User Can\'t find your school? Try typing the full school URL. - Tap here for help. No Internet Connection This action requires an internet connection. diff --git a/libs/login-api-2/src/main/res/values-es-rES/strings.xml b/libs/login-api-2/src/main/res/values-es-rES/strings.xml index 34f0ec1e70..feeaaaa281 100644 --- a/libs/login-api-2/src/main/res/values-es-rES/strings.xml +++ b/libs/login-api-2/src/main/res/values-es-rES/strings.xml @@ -110,7 +110,6 @@ school.instructure.com Eliminar usuario anterior ¿No puedes encontrar tu escuela? Intenta escribir la URL completa de la escuela. - Toca aquí para obtener ayuda. No hay conexión a Internet Esta acción requiere conexión a Internet. diff --git a/libs/login-api-2/src/main/res/values-es/strings.xml b/libs/login-api-2/src/main/res/values-es/strings.xml index f87d909f6e..3b4c42e957 100644 --- a/libs/login-api-2/src/main/res/values-es/strings.xml +++ b/libs/login-api-2/src/main/res/values-es/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Eliminar usuario anterior ¿No puede encontrar su escuela? Intente escribir la URL completa de la escuela. - Presione aquí para obtener ayuda. Sin conexión a Internet Esta acción requiere conexión a Internet. diff --git a/libs/login-api-2/src/main/res/values-fi/strings.xml b/libs/login-api-2/src/main/res/values-fi/strings.xml index 4157a60613..f64c603f8d 100644 --- a/libs/login-api-2/src/main/res/values-fi/strings.xml +++ b/libs/login-api-2/src/main/res/values-fi/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Poista edellinen käyttäjä Etkö löydä kouluasi? Yritä kirjoittaa koko koulun URL - Avaa ohje napauttamalla tässä. Ei Internet-yhteyttä Tähän toimintoon vaaditaan Internet-yhteys. diff --git a/libs/login-api-2/src/main/res/values-fr-rCA/strings.xml b/libs/login-api-2/src/main/res/values-fr-rCA/strings.xml index 060153b513..3ca2376d4b 100644 --- a/libs/login-api-2/src/main/res/values-fr-rCA/strings.xml +++ b/libs/login-api-2/src/main/res/values-fr-rCA/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Retirer l\'utilisateur précédent Vous ne trouvez pas votre école? Essayez de saisir l\'URL complète de l’école. - Appuyer ici pour obtenir de l\'aide. Aucune connexion Internet Cette action nécessite une connexion Internet. diff --git a/libs/login-api-2/src/main/res/values-fr/strings.xml b/libs/login-api-2/src/main/res/values-fr/strings.xml index d2a7dedb52..538a698666 100644 --- a/libs/login-api-2/src/main/res/values-fr/strings.xml +++ b/libs/login-api-2/src/main/res/values-fr/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Supprimer utilisateur précédent Vous ne trouvez pas votre école ? Essayez d’entrer l’URL complète de l’école - Appuyez ici pour obtenir de l’aide. Aucune connexion internet Cette action nécessite une connexion Internet. diff --git a/libs/login-api-2/src/main/res/values-ht/strings.xml b/libs/login-api-2/src/main/res/values-ht/strings.xml index 2eea2ac773..4303be39f4 100644 --- a/libs/login-api-2/src/main/res/values-ht/strings.xml +++ b/libs/login-api-2/src/main/res/values-ht/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Elimine Ansyen Itilizatè Ou paka jwenn lekòl ou a? Eseye tape tout URL lekòl la - Tape la pou èd. Pa gen Koneksyon Internet Pou aksyon sa a ou bezwen konekte sou entènèt. diff --git a/libs/login-api-2/src/main/res/values-is/strings.xml b/libs/login-api-2/src/main/res/values-is/strings.xml index ab6760cb70..4ac89d3b60 100644 --- a/libs/login-api-2/src/main/res/values-is/strings.xml +++ b/libs/login-api-2/src/main/res/values-is/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Fjarlægja fyrri notanda Finnurðu ekki skólann þinn? Prufaðu að slá inn alla vefslóð skólans. - Smelltu hér til að fá hjálp. Engin nettenging Þessi aðgerð krefst nettengingar. diff --git a/libs/login-api-2/src/main/res/values-it/strings.xml b/libs/login-api-2/src/main/res/values-it/strings.xml index 541ff2dc4e..675781a920 100644 --- a/libs/login-api-2/src/main/res/values-it/strings.xml +++ b/libs/login-api-2/src/main/res/values-it/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Rimuovi utente precedente Non riesci a trova la tua scuola? Prova a digitare l’URL della scuola per intero. - Tocca qui per assistenza. Nessuna connessione a Internet Questa azione richiede una connessione a Internet. diff --git a/libs/login-api-2/src/main/res/values-ja/strings.xml b/libs/login-api-2/src/main/res/values-ja/strings.xml index 01da4c3a5d..13acaf4ee3 100644 --- a/libs/login-api-2/src/main/res/values-ja/strings.xml +++ b/libs/login-api-2/src/main/res/values-ja/strings.xml @@ -109,7 +109,6 @@ school.instructure.com 以前のユーザーを削除 学校が見つかりませんか?学校の完全なURLを入力してみてください。 - ここをタップしてヘルプを表示します。 インターネット接続なし この操作にはインターネット接続が必要です。 diff --git a/libs/login-api-2/src/main/res/values-mi/strings.xml b/libs/login-api-2/src/main/res/values-mi/strings.xml index 772695e3af..59b0fc3de9 100644 --- a/libs/login-api-2/src/main/res/values-mi/strings.xml +++ b/libs/login-api-2/src/main/res/values-mi/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Tango Kaiwhakmahi o mua Kaore e kitea tō kura? Ngana ki te pātō te nuinga o te kura url - Pātō ki kōnei mo te awhina Kaore he hononga ipurangi Tēnei mahi ka mahi he hononga ipurangi diff --git a/libs/login-api-2/src/main/res/values-ms/strings.xml b/libs/login-api-2/src/main/res/values-ms/strings.xml index b8be733a44..4229891bcc 100644 --- a/libs/login-api-2/src/main/res/values-ms/strings.xml +++ b/libs/login-api-2/src/main/res/values-ms/strings.xml @@ -110,7 +110,6 @@ school.instructure.com Alih Keluar Pengguna Sebelumnya Tidak menemui sekolah anda? Cuba taip URL sekolah yang penuh. - Ketik di sini untuk mendapatkan bantuan. Tiada Sambungan Internet Tindakan ini memerlukan sambungan Internet diff --git a/libs/login-api-2/src/main/res/values-nb/strings.xml b/libs/login-api-2/src/main/res/values-nb/strings.xml index 1d7d7e6851..e062fe3ba6 100644 --- a/libs/login-api-2/src/main/res/values-nb/strings.xml +++ b/libs/login-api-2/src/main/res/values-nb/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Fjerne forrige bruker Finner du ikke skolen din? Prøv å skrive hele skolens URL. - Trykk her for hjelp. Ingen Internett-tilkobling Denne handlingen krever Internett-tilkobling. diff --git a/libs/login-api-2/src/main/res/values-nl/strings.xml b/libs/login-api-2/src/main/res/values-nl/strings.xml index 0e4ae4a8f3..5b4e4a596b 100644 --- a/libs/login-api-2/src/main/res/values-nl/strings.xml +++ b/libs/login-api-2/src/main/res/values-nl/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Vorige gebruiker verwijderen Kun je je school niet vinden? Typ de volledige URL van de school. - Tik hier voor hulp. Geen internetverbinding Voor deze actie is een internetverbinding vereist. diff --git a/libs/login-api-2/src/main/res/values-pl/strings.xml b/libs/login-api-2/src/main/res/values-pl/strings.xml index 552807641f..64e3193940 100644 --- a/libs/login-api-2/src/main/res/values-pl/strings.xml +++ b/libs/login-api-2/src/main/res/values-pl/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Usuń poprzedniego użytkownika Nie możesz znaleźć swojej szkoły? Spróbuj wprowadzić pełen adres URL szkoły. - Stuknij tutaj, aby uzyskać pomoc. Brak połączenia internetowego To działanie wymaga połączenia internetowego. diff --git a/libs/login-api-2/src/main/res/values-pt-rBR/strings.xml b/libs/login-api-2/src/main/res/values-pt-rBR/strings.xml index 24542db009..21fd1ffc8e 100644 --- a/libs/login-api-2/src/main/res/values-pt-rBR/strings.xml +++ b/libs/login-api-2/src/main/res/values-pt-rBR/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Remover usuário anterior Não consegue localizar a sua escola? Tente digitar a URL completa da escola. - Toque aqui para ajuda. Sem conexão à internet Esta ação exige conexão à internet. diff --git a/libs/login-api-2/src/main/res/values-pt-rPT/strings.xml b/libs/login-api-2/src/main/res/values-pt-rPT/strings.xml index e6147e54df..549dc0523c 100644 --- a/libs/login-api-2/src/main/res/values-pt-rPT/strings.xml +++ b/libs/login-api-2/src/main/res/values-pt-rPT/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Remover utilizador anterior? Não vê sua escola? Tente digitar o URL completo da escola. - Toque aqui para ajuda. Sem ligação à Internet Esta ação requer uma conexão com a internet. diff --git a/libs/login-api-2/src/main/res/values-ru/strings.xml b/libs/login-api-2/src/main/res/values-ru/strings.xml index b2730ba446..ecaff5b7c8 100644 --- a/libs/login-api-2/src/main/res/values-ru/strings.xml +++ b/libs/login-api-2/src/main/res/values-ru/strings.xml @@ -109,7 +109,6 @@ school.instructure.com Удалить предыдущего пользователя Не удается найти свое учебное заведение? Попробуйте набрать полный адрес URL учебного заведения. - Прикоснитесь здесь для получения помощи. Нет интернет-соединения Для выполнения этого действия необходимо интернет-соединение. diff --git a/libs/login-api-2/src/main/res/values-sl/strings.xml b/libs/login-api-2/src/main/res/values-sl/strings.xml index 22206a77c5..9501e8172f 100644 --- a/libs/login-api-2/src/main/res/values-sl/strings.xml +++ b/libs/login-api-2/src/main/res/values-sl/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Odstrani predhodnega uporabnika Ali svoje šole ne najdete? Poskusite vnesti poln naslov URL šole. - Za pomoč tapnite tukaj. Brez internetne povezave. Pri tem dejanju je potrebna internetna povezava. diff --git a/libs/login-api-2/src/main/res/values-sv/strings.xml b/libs/login-api-2/src/main/res/values-sv/strings.xml index 6545349e99..cd28a78c01 100644 --- a/libs/login-api-2/src/main/res/values-sv/strings.xml +++ b/libs/login-api-2/src/main/res/values-sv/strings.xml @@ -111,7 +111,6 @@ school.instructure.com Ta bort tidigare användare Kan du inte hitta din skola? Försök att skriva in skolans fullständiga URL. - Tryck här för hjälp. Ingen internetanslutning Den här åtgärden kräver internetanslutning. diff --git a/libs/login-api-2/src/main/res/values-th/strings.xml b/libs/login-api-2/src/main/res/values-th/strings.xml index 5ca4798884..fe43425e2e 100644 --- a/libs/login-api-2/src/main/res/values-th/strings.xml +++ b/libs/login-api-2/src/main/res/values-th/strings.xml @@ -110,7 +110,6 @@ school.instructure.com ลบผู้ใช้ก่อนหน้า ไม่พบโรงเรียนของคุณ ลองพิมพ์ URL เต็มของสถานศึกษา - กดเลือกที่นี่เพื่อรับความช่วยเหลือ ไม่มีการเชื่อมต่ออินเทอร์เน็ต การดำเนินการนี้ต้องมีการเชื่อมต่ออินเทอร์เน็ต diff --git a/libs/login-api-2/src/main/res/values-vi/strings.xml b/libs/login-api-2/src/main/res/values-vi/strings.xml index 0b1ef97420..301d0f6c50 100644 --- a/libs/login-api-2/src/main/res/values-vi/strings.xml +++ b/libs/login-api-2/src/main/res/values-vi/strings.xml @@ -110,7 +110,6 @@ school.instructure.com Loại Bỏ Người Dùng Trước Không tìm được trường của bạn? Hãy thử nhập URL đầy đủ của trường - Nhấn vào đây để được trợ giúp. Không Có Kết Nối Internet Thao tác này bắt buộc phải có kết nối internet. diff --git a/libs/login-api-2/src/main/res/values-zh/strings.xml b/libs/login-api-2/src/main/res/values-zh/strings.xml index 871b422f6d..72d0ea9c83 100644 --- a/libs/login-api-2/src/main/res/values-zh/strings.xml +++ b/libs/login-api-2/src/main/res/values-zh/strings.xml @@ -109,7 +109,6 @@ school.instructure.com 删除上一个用户 找不到您的学校?尝试键入完整的学校 URL。 - 轻击此处获取帮助。 无互联网连接 此操作需要互联网连接。 diff --git a/libs/login-api-2/src/main/res/values/strings.xml b/libs/login-api-2/src/main/res/values/strings.xml index 4acc7c102b..493fa8837d 100644 --- a/libs/login-api-2/src/main/res/values/strings.xml +++ b/libs/login-api-2/src/main/res/values/strings.xml @@ -112,7 +112,7 @@ F school.instructure.com Remove Previous User Can\'t find your school? Try typing the full school URL. - Tap here for help. + Tap here for login help. No Internet Connection This action requires an internet connection. From a9f5c02a82e2073c4912fd8a4732e130da6752a0 Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Tue, 22 Aug 2023 09:53:22 +0200 Subject: [PATCH 39/61] [MBL-16972][All] Add alt text to the "Instructure" image at the bottom of the "About" dialog in Androids User Settings refs: MBL-16972 affects: Student, Teacher, Parent release note: none --- apps/flutter_parent/lib/l10n/app_localizations.dart | 3 +++ .../lib/screens/settings/settings_interactor.dart | 1 + libs/pandares/src/main/res/values/strings.xml | 1 + libs/pandautils/src/main/res/layout/fragment_about.xml | 2 +- 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/flutter_parent/lib/l10n/app_localizations.dart b/apps/flutter_parent/lib/l10n/app_localizations.dart index 26fa996e0b..4872b5fe70 100644 --- a/apps/flutter_parent/lib/l10n/app_localizations.dart +++ b/apps/flutter_parent/lib/l10n/app_localizations.dart @@ -1705,4 +1705,7 @@ class AppLocalizations { String get aboutVersionTitle => Intl.message('Version', desc: 'Title for Version field on about page'); + + String get aboutLogoSemanticsLabel => + Intl.message('Instructure logo', desc: 'Semantics label for the Instructure logo on the about page'); } diff --git a/apps/flutter_parent/lib/screens/settings/settings_interactor.dart b/apps/flutter_parent/lib/screens/settings/settings_interactor.dart index 74dffb7968..652a26fc44 100644 --- a/apps/flutter_parent/lib/screens/settings/settings_interactor.dart +++ b/apps/flutter_parent/lib/screens/settings/settings_interactor.dart @@ -96,6 +96,7 @@ class SettingsInteractor { SvgPicture.asset( 'assets/svg/ic_instructure_logo.svg', alignment: Alignment.bottomCenter, + semanticsLabel: L10n(context).aboutLogoSemanticsLabel, ) ], ), diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index c1648b020a..01f7f5f4cd 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -1400,4 +1400,5 @@ Email Version There was a problem reloading this assignment. Please check your connection and try again. + Instructure logo diff --git a/libs/pandautils/src/main/res/layout/fragment_about.xml b/libs/pandautils/src/main/res/layout/fragment_about.xml index 5d26338930..12b2463385 100644 --- a/libs/pandautils/src/main/res/layout/fragment_about.xml +++ b/libs/pandautils/src/main/res/layout/fragment_about.xml @@ -139,7 +139,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="32dp" - android:importantForAccessibility="no" + android:contentDescription="@string/instructure_logo" android:src="@drawable/ic_instructure_logo" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" From 67e0bf4cfc0066a72850c2c6f5d77b84a4c3835b Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Tue, 22 Aug 2023 09:53:51 +0200 Subject: [PATCH 40/61] [All] Fixed accessibility issues (#2109) refs: MBL-16982, MBL-16975, MBL-16977, MBL-16984, MBL-16971, MBL-16978, MBL-16985 affects: All release note: Fixed accessibility issues * MBL-16982 Changed green and red colors to have 4.5:1 contrast with the background. * MBL-16975 Fixed file submission icon contrast. * MBL-16977 Fixed course name and home page contrast. * MBL-16984 Fixed info color contrast * MBL-16971 Fixed syllabus contrast. * MBL-16978 Redesigned rating dialog. * MBL-16985 Fixed contrast and content description for Canvas text on the login screen. * Test fixes --- .../svg/canvas-parent-login-logo-dark.svg | 10 ++++++++ .../assets/svg/canvas-parent-login-logo.svg | 12 +++++----- .../lib/screens/login_landing_screen.dart | 8 ++----- .../AssignmentDetailsViewModel.kt | 6 ++--- .../src/main/res/layout/fragment_syllabus.xml | 4 +++- .../layout/fragment_unsupported_file_type.xml | 2 +- .../AssignmentDetailsViewModelTest.kt | 8 +++---- .../src/main/res/layout/fragment_syllabus.xml | 4 +++- .../layout/fragment_unsupported_file_type.xml | 2 +- .../res/layout/view_edit_course_homepage.xml | 2 +- .../main/res/layout/view_rename_course.xml | 2 +- .../layout/activity_login_landing_page.xml | 4 ++-- .../src/main/res/drawable/ic_rating_star.xml | 5 ++++ .../res/drawable/ic_rating_star_outline.xml | 5 ++++ libs/pandares/src/main/res/values/colors.xml | 24 +++++++++---------- .../pandautils/dialogs/RatingDialog.kt | 7 ++---- .../src/main/res/layout/dialog_rating.xml | 20 ++++++++-------- .../src/main/res/values/font_styles.xml | 4 ++++ 18 files changed, 75 insertions(+), 54 deletions(-) create mode 100644 apps/flutter_parent/assets/svg/canvas-parent-login-logo-dark.svg create mode 100644 libs/pandares/src/main/res/drawable/ic_rating_star.xml create mode 100644 libs/pandares/src/main/res/drawable/ic_rating_star_outline.xml diff --git a/apps/flutter_parent/assets/svg/canvas-parent-login-logo-dark.svg b/apps/flutter_parent/assets/svg/canvas-parent-login-logo-dark.svg new file mode 100644 index 0000000000..cfc8e0bc77 --- /dev/null +++ b/apps/flutter_parent/assets/svg/canvas-parent-login-logo-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/flutter_parent/assets/svg/canvas-parent-login-logo.svg b/apps/flutter_parent/assets/svg/canvas-parent-login-logo.svg index a6413426ae..a7729978ef 100644 --- a/apps/flutter_parent/assets/svg/canvas-parent-login-logo.svg +++ b/apps/flutter_parent/assets/svg/canvas-parent-login-logo.svg @@ -1,10 +1,10 @@ - - - - - - + + + + + + diff --git a/apps/flutter_parent/lib/screens/login_landing_screen.dart b/apps/flutter_parent/lib/screens/login_landing_screen.dart index 7c66ea9cf9..af81ff724f 100644 --- a/apps/flutter_parent/lib/screens/login_landing_screen.dart +++ b/apps/flutter_parent/lib/screens/login_landing_screen.dart @@ -18,19 +18,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_parent/l10n/app_localizations.dart'; import 'package:flutter_parent/models/login.dart'; import 'package:flutter_parent/models/school_domain.dart'; -import 'package:flutter_parent/network/utils/analytics.dart'; import 'package:flutter_parent/network/utils/api_prefs.dart'; import 'package:flutter_parent/router/panda_router.dart'; import 'package:flutter_parent/screens/qr_login/qr_login_util.dart'; import 'package:flutter_parent/screens/web_login/web_login_screen.dart'; import 'package:flutter_parent/utils/common_widgets/avatar.dart'; -import 'package:flutter_parent/utils/common_widgets/error_report/error_report_dialog.dart'; -import 'package:flutter_parent/utils/common_widgets/error_report/error_report_interactor.dart'; -import 'package:flutter_parent/utils/common_widgets/full_screen_scroll_container.dart'; import 'package:flutter_parent/utils/common_widgets/two_finger_double_tap_gesture_detector.dart'; import 'package:flutter_parent/utils/common_widgets/user_name.dart'; import 'package:flutter_parent/utils/debug_flags.dart'; -import 'package:flutter_parent/utils/design/canvas_icons.dart'; import 'package:flutter_parent/utils/design/canvas_icons_solid.dart'; import 'package:flutter_parent/utils/design/parent_colors.dart'; import 'package:flutter_parent/utils/design/parent_theme.dart'; @@ -93,13 +88,14 @@ class LoginLandingScreen extends StatelessWidget { Widget _body(BuildContext context) { final lastLoginAccount = ApiPrefs.getLastAccount(); + final assetString = ParentTheme.of(context).isDarkMode ? 'assets/svg/canvas-parent-login-logo-dark.svg' : 'assets/svg/canvas-parent-login-logo.svg'; return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Spacer(), SvgPicture.asset( - 'assets/svg/canvas-parent-login-logo.svg', + assetString, semanticsLabel: L10n(context).canvasLogoLabel, ), Spacer(), diff --git a/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt b/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt index 0a7f57a4ac..1155fdcead 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt @@ -268,11 +268,11 @@ class AssignmentDetailsViewModel @Inject constructor( ) val submissionStatusTint = if (assignment.isSubmitted) { - R.color.backgroundSuccess + R.color.textSuccess } else if (isMissing) { - R.color.backgroundDanger + R.color.textDanger } else { - R.color.backgroundDark + R.color.textDark } val submittedStatusIcon = if (assignment.isSubmitted) R.drawable.ic_complete_solid else R.drawable.ic_no diff --git a/apps/student/src/main/res/layout/fragment_syllabus.xml b/apps/student/src/main/res/layout/fragment_syllabus.xml index f9baf996b6..cb801b5f90 100644 --- a/apps/student/src/main/res/layout/fragment_syllabus.xml +++ b/apps/student/src/main/res/layout/fragment_syllabus.xml @@ -46,7 +46,9 @@ app:tabPaddingEnd="4dp" app:tabPaddingStart="4dp" app:tabSelectedTextColor="@color/white" - app:tabTextColor="@color/transparentWhite" + app:tabTextColor="@color/white" + app:tabTextAppearance="@style/TextAppearance.Design.Tab" + app:tabSelectedTextAppearance="@style/NavigationTabTextAppeareance" tools:background="#00bcd5" tools:visibility="visible"> diff --git a/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml b/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml index 202c4d34cd..eb03f71252 100644 --- a/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml +++ b/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml @@ -48,7 +48,7 @@ android:layout_height="64dp" android:importantForAccessibility="no" android:scaleType="fitCenter" - android:tint="@color/textLight" + app:tint="@color/textDark" app:srcCompat="@drawable/ic_canvas_logo_red"/> diff --git a/apps/teacher/src/main/res/layout/fragment_unsupported_file_type.xml b/apps/teacher/src/main/res/layout/fragment_unsupported_file_type.xml index 568c075201..fb0542d8bf 100644 --- a/apps/teacher/src/main/res/layout/fragment_unsupported_file_type.xml +++ b/apps/teacher/src/main/res/layout/fragment_unsupported_file_type.xml @@ -47,7 +47,7 @@ android:layout_height="64dp" android:importantForAccessibility="no" android:scaleType="fitCenter" - android:tint="@color/textLight" + app:tint="@color/textDark" app:srcCompat="@drawable/ic_canvas_logo" /> diff --git a/apps/teacher/src/main/res/layout/view_rename_course.xml b/apps/teacher/src/main/res/layout/view_rename_course.xml index e1b2afcd22..4594143d7d 100644 --- a/apps/teacher/src/main/res/layout/view_rename_course.xml +++ b/apps/teacher/src/main/res/layout/view_rename_course.xml @@ -56,7 +56,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@id/courseNameLabel" - android:alpha="0.55" + android:textColor="@color/textDark" android:freezesText="true" tools:text="Biology 101"/> diff --git a/libs/login-api-2/src/main/res/layout/activity_login_landing_page.xml b/libs/login-api-2/src/main/res/layout/activity_login_landing_page.xml index b41bb274f1..22adb062e5 100644 --- a/libs/login-api-2/src/main/res/layout/activity_login_landing_page.xml +++ b/libs/login-api-2/src/main/res/layout/activity_login_landing_page.xml @@ -55,8 +55,8 @@ android:layout_marginTop="24dp" android:layout_marginBottom="2dp" android:adjustViewBounds="true" - android:importantForAccessibility="no" - android:tint="@color/tiara" + android:contentDescription="@string/canvas" + android:tint="@color/textDarkest" app:srcCompat="@drawable/ic_canvas_wordmark" /> + + diff --git a/libs/pandares/src/main/res/drawable/ic_rating_star_outline.xml b/libs/pandares/src/main/res/drawable/ic_rating_star_outline.xml new file mode 100644 index 0000000000..c32749453d --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_rating_star_outline.xml @@ -0,0 +1,5 @@ + + + diff --git a/libs/pandares/src/main/res/values/colors.xml b/libs/pandares/src/main/res/values/colors.xml index 5843539a2c..0adee136e1 100644 --- a/libs/pandares/src/main/res/values/colors.xml +++ b/libs/pandares/src/main/res/values/colors.xml @@ -20,44 +20,44 @@ #556572 #BF32A4 - #EE0612 + #E0061F #556572 #2D3B45 #F5F5F5 #FFFFFF - #008EE2 + #0374B5 #F5F5F5 #FFFFFF #FFFFFF #C7CDD1 - #00AC18 + #0B874B #FC5E13 #BF32A4 #BF32A4 - #EE0612 + #E0061F #556572 #2D3B45 - #008EE2 + #0374B5 #F5F5F5 #FFFFFF #C7CDD1 - #00AC18 + #0B874B #FC5E13 - #EE0612 - #008EE2 + #E0061F + #0374B5 #FC5E13 #2D3B45 #394B58 #F5F5F5 - #00AC18 + #0B874B #BF32A4 - #EE0612 + #E0061F #556572 #2D3B45 - #008EE2 + #0374B5 #F5F5F5 #FFFFFF - #00AC18 + #0B874B #FC5E13 #C7CDD1 #FFFFFF diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/RatingDialog.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/RatingDialog.kt index 04cf4f703d..7745073010 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/RatingDialog.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/RatingDialog.kt @@ -113,13 +113,11 @@ class RatingDialog : DialogFragment() { val starClickListener = View.OnClickListener { v -> stars.forEach { - it.setImageResource(R.drawable.ic_star) - it.setColorFilter(requireContext().getColor(R.color.backgroundMedium)) + it.setImageResource(R.drawable.ic_rating_star_outline) } val selectionIndex = stars.indexOf(v) stars.take(selectionIndex + 1).forEach { - it.setImageResource(R.drawable.ic_star) - it.setColorFilter(requireContext().getColor(R.color.backgroundDark)) + it.setImageResource(R.drawable.ic_rating_star) } val isFiveStars = selectionIndex >= 4 comments.setVisible(!isFiveStars) @@ -132,7 +130,6 @@ class RatingDialog : DialogFragment() { } stars.forEach { - it.setImageResource(R.drawable.ic_star) it.setOnClickListener(starClickListener) } } diff --git a/libs/pandautils/src/main/res/layout/dialog_rating.xml b/libs/pandautils/src/main/res/layout/dialog_rating.xml index 007ed070e7..ab56eb6e20 100644 --- a/libs/pandautils/src/main/res/layout/dialog_rating.xml +++ b/libs/pandautils/src/main/res/layout/dialog_rating.xml @@ -39,8 +39,8 @@ android:layout_marginLeft="4dp" android:layout_marginRight="4dp" android:contentDescription="@string/star1" - android:src="@drawable/ic_star" - app:tint="@color/backgroundMedium" /> + android:src="@drawable/ic_rating_star_outline" + app:tint="@color/backgroundDark" /> + android:src="@drawable/ic_rating_star_outline" + app:tint="@color/backgroundDark"/> + android:src="@drawable/ic_rating_star_outline" + app:tint="@color/backgroundDark"/> + android:src="@drawable/ic_rating_star_outline" + app:tint="@color/backgroundDark"/> + android:src="@drawable/ic_rating_star_outline" + app:tint="@color/backgroundDark"/> diff --git a/libs/pandautils/src/main/res/values/font_styles.xml b/libs/pandautils/src/main/res/values/font_styles.xml index 1e19ec8b65..51fa9e391c 100644 --- a/libs/pandautils/src/main/res/values/font_styles.xml +++ b/libs/pandautils/src/main/res/values/font_styles.xml @@ -42,4 +42,8 @@ italic + + \ No newline at end of file From 454740d1d0ed6f6f7a19c7ef30a8e03bc42bcc5f Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Tue, 22 Aug 2023 12:32:24 +0200 Subject: [PATCH 41/61] Fix many breaking nightly tests in both apps (#2114) * Rollback lowres devices to API lvl 26 (because API level 29 low resolution device is too flaky). * fix teacher announcement and discussion e2e tests * fix files e2e test in teacher * fix breaking syllabus tablet test. fix breaking discussion test. * fix breaking landscape module tests * Stub NotATeacherPageTest displaysPageObjects test because of the 'too many login attempts' change on the backend. (Will be put back after find found a solution for it) --- .../ui/interaction/ModuleInteractionTest.kt | 338 ++++++++++++------ .../teacher/ui/NotATeacherPageTest.kt | 2 + .../teacher/ui/e2e/DiscussionsE2ETest.kt | 12 +- .../teacher/ui/e2e/FilesE2ETest.kt | 2 +- .../teacher/ui/pages/AnnouncementsListPage.kt | 2 +- .../teacher/ui/pages/CalendarEventPage.kt | 4 +- 6 files changed, 232 insertions(+), 128 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt index c35844fb1b..20c99a602c 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt @@ -18,12 +18,36 @@ package com.instructure.student.ui.interaction import android.text.Html import androidx.test.espresso.Espresso import androidx.test.espresso.web.webdriver.Locator -import com.instructure.canvas.espresso.mockCanvas.* -import com.instructure.canvasapi2.models.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addDiscussionTopicToCourse +import com.instructure.canvas.espresso.mockCanvas.addFileToCourse +import com.instructure.canvas.espresso.mockCanvas.addItemToModule +import com.instructure.canvas.espresso.mockCanvas.addLTITool +import com.instructure.canvas.espresso.mockCanvas.addModuleToCourse +import com.instructure.canvas.espresso.mockCanvas.addPageToCourse +import com.instructure.canvas.espresso.mockCanvas.addQuestionToQuiz +import com.instructure.canvas.espresso.mockCanvas.addQuizToCourse +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CourseSettings +import com.instructure.canvasapi2.models.DiscussionTopicHeader +import com.instructure.canvasapi2.models.LockInfo +import com.instructure.canvasapi2.models.LockedModule +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.canvasapi2.models.Page +import com.instructure.canvasapi2.models.Quiz +import com.instructure.canvasapi2.models.QuizAnswer +import com.instructure.canvasapi2.models.Tab import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.panda_annotations.* +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.SecondaryFeatureCategory +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData import com.instructure.student.R import com.instructure.student.ui.pages.WebViewTextCheck import com.instructure.student.ui.utils.StudentTest @@ -43,7 +67,6 @@ class ModuleInteractionTest : StudentTest() { private var page: Page? = null private val fileName = "ModuleFile.html" private var fileCheck: WebViewTextCheck? = null - private val externalUrl = "https://www.google.com" private var quiz: Quiz? = null // Tapping an Assignment module item should navigate to that item's detail page @@ -55,7 +78,19 @@ class ModuleInteractionTest : StudentTest() { val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + // Create an assignment and add it as a module item + assignment = data.addAssignment( + courseId = course1.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = assignment!! + ) + // Verify that we can launch into the assignment from an assignment module item + modulesPage.refresh() modulesPage.assertModuleDisplayed(module) modulesPage.assertModuleItemDisplayed(module, assignment!!.name!!) modulesPage.clickModuleItem(module, assignment!!.name!!) @@ -71,8 +106,23 @@ class ModuleInteractionTest : StudentTest() { val data = getToCourseModules(studentCount = 1, courseCount = 1) val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + val user = data.users.values.first() + + // Create a discussion and add it as a module item + topicHeader = data.addDiscussionTopicToCourse( + course = course1, + user = user, + topicTitle = "Discussion in module", + topicDescription = "In. A. Module." + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = topicHeader!! + ) // Verify that we can launch into a discussion from a discussion module item + modulesPage.refresh() modulesPage.assertModuleDisplayed(module) modulesPage.assertModuleItemDisplayed(module, topicHeader!!.title!!) modulesPage.clickModuleItem(module, topicHeader!!.title!!) @@ -87,6 +137,14 @@ class ModuleInteractionTest : StudentTest() { val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + val ltiTool = data.addLTITool("Google Drive", "http://google.com", course1, 1234L) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = ltiTool!! + ) + + modulesPage.refresh() modulesPage.clickModuleItem(module, "Google Drive") canvasWebViewPage.assertTitle("Google Drive") } @@ -96,11 +154,20 @@ class ModuleInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.MODULES, TestCategory.INTERACTION, false) fun testModules_launchesIntoExternalURL() { // Basic mock setup + val externalUrl = "https://www.google.com" val data = getToCourseModules(studentCount = 1, courseCount = 1) val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + // Create an external URL and add it as a module item + data.addItemToModule( + course = course1, + moduleId = module.id, + item = externalUrl + ) + // click the external url module item + modulesPage.refresh() modulesPage.clickModuleItem(module, externalUrl) // Not much we can test here, as it is an external URL, but testModules_navigateToNextAndPreviousModuleItems // will test that the module name and module item name are displayed correctly. @@ -116,7 +183,26 @@ class ModuleInteractionTest : StudentTest() { val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + // Create a file and add it as a module item + val fileContent = "

A Heading

" + fileCheck = WebViewTextCheck(Locator.ID, "heading1", "A Heading") + + val fileId = data.addFileToCourse( + courseId = course1.id, + displayName = fileName, + fileContent = fileContent, + contentType = "text/html" + ) + val rootFolderId = data.courseRootFolders[course1.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + data.addItemToModule( + course = course1, + moduleId = module.id, + item = fileFolder!! + ) + // Click the file module and verify that the file appears + modulesPage.refresh() modulesPage.clickModuleItem(module, fileName, R.id.openButton) canvasWebViewPage.waitForWebView() canvasWebViewPage.runTextChecks(fileCheck!!) @@ -131,7 +217,22 @@ class ModuleInteractionTest : StudentTest() { val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + // Create a page and add it as a module item + page = data.addPageToCourse( + courseId = course1.id, + pageId = data.newItemId(), + published = true, + title = "Page In Course", + url = URLEncoder.encode("Page In Course", "UTF-8") + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = page!! + ) + // Verify that we can launch into a page from a page module item + modulesPage.refresh() modulesPage.assertModuleDisplayed(module) modulesPage.assertModuleItemDisplayed(module, page!!.title!!) modulesPage.clickModuleItem(module, page!!.title!!) @@ -157,7 +258,48 @@ class ModuleInteractionTest : StudentTest() { val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + // Create a quiz and add it as a module item + quiz = data.addQuizToCourse( + course = course1 + ) + + data.addQuestionToQuiz( + course = course1, + quizId = quiz!!.id, + questionName = "Math 1", + questionText = "What is 2 + 5?", + questionType = "multiple_choice_question", + answers = arrayOf( + QuizAnswer(answerText = "7"), + QuizAnswer(answerText = "25"), + QuizAnswer(answerText = "-7") + ) + ) + + data.addQuestionToQuiz( + course = course1, + quizId = quiz!!.id, + questionName = "Math 2", + questionText = "Pi is greater than the square root of 2", + questionType = "true_false_question" + ) + + data.addQuestionToQuiz( + course = course1, + quizId = quiz!!.id, + questionName = "Math 3", + questionText = "Write an essay on why math is so awesome", + questionType = "essay_question" + ) + + data.addItemToModule( + course = course1, + moduleId = module.id, + item = quiz!! + ) + // Verify that we can launch into a quiz from a quiz module item + modulesPage.refresh() modulesPage.assertModuleDisplayed(module) modulesPage.assertModuleItemDisplayed(module, quiz!!.title!!) /* TODO: Check that the quiz is displayed if/when we can do so via WebView @@ -177,7 +319,20 @@ class ModuleInteractionTest : StudentTest() { // Basic mock setup val data = getToCourseModules(studentCount = 1, courseCount = 1) val course1 = data.courses.values.first() - val module = data.courseModules[course1.id]!!.first() + var module = data.courseModules[course1.id]!!.first() + + // Create an assignment and add it as a module item + assignment = data.addAssignment( + courseId = course1.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = assignment!! + ) + + module = data.courseModules[course1.id]!!.first() val firstModuleItem = module.items[0] // Verify that expanding a module shows the module items and collapsing a module @@ -185,6 +340,7 @@ class ModuleInteractionTest : StudentTest() { // We're going on the assumption that the lone module is initially expanded. Although // the initial assertModuleItemDisplayed() would expand the module if it was not expanded // already. + modulesPage.refresh() modulesPage.assertModuleDisplayed(module) modulesPage.assertModuleItemDisplayed(module, firstModuleItem.title!!) modulesPage.clickModule(module) @@ -221,11 +377,68 @@ class ModuleInteractionTest : StudentTest() { fun testModules_navigateToNextAndPreviousModuleItems() { // Basic mock setup val data = getToCourseModules(studentCount = 1, courseCount = 1) + val externalUrl = "https://www.google.com" val course1 = data.courses.values.first() - val module = data.courseModules[course1.id]!!.first() + var module = data.courseModules[course1.id]!!.first() + val user = data.users.values.first() + + // Create an assignment and add it as a module item + assignment = data.addAssignment( + courseId = course1.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = assignment!! + ) + + // Create a discussion and add it as a module item + topicHeader = data.addDiscussionTopicToCourse( + course = course1, + user = user, + topicTitle = "Discussion in module", + topicDescription = "In. A. Module." + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = topicHeader!! + ) + + val ltiTool = data.addLTITool("Google Drive", "http://google.com", course1, 1234L) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = ltiTool!! + ) + + // Create a page and add it as a module item + page = data.addPageToCourse( + courseId = course1.id, + pageId = data.newItemId(), + published = true, + title = "Page In Course", + url = URLEncoder.encode("Page In Course", "UTF-8") + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = page!! + ) + + // Create an external URL and add it as a module item + data.addItemToModule( + course = course1, + moduleId = module.id, + item = externalUrl + ) + + module = data.courseModules[course1.id]!!.first() // Iterate through the module items, starting at the first val moduleItemList = module.items + modulesPage.refresh() modulesPage.clickModuleItem(module, moduleItemList[0].title!!) var moduleIndex = 0; // we start here @@ -413,126 +626,15 @@ class ModuleInteractionTest : StudentTest() { // Add a course tab val course1 = data.courses.values.first() - val user1 = data.users.values.first() val modulesTab = Tab(position = 2, label = "Modules", visibility = "public", tabId = Tab.MODULES_ID) data.courseTabs[course1.id]!! += modulesTab // Create a module - val module = data.addModuleToCourse( + data.addModuleToCourse( course = course1, moduleName = "Big Module" ) - // Create a discussion and add it as a module item - topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion in module", - topicDescription = "In. A. Module." - ) - data.addItemToModule( - course = course1, - moduleId = module.id, - item = topicHeader!! - ) - - // Create an assignment and add it as a module item - assignment = data.addAssignment( - courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY - ) - data.addItemToModule( - course = course1, - moduleId = module.id, - item = assignment!! - ) - - // Create a page and add it as a module item - page = data.addPageToCourse( - courseId = course1.id, - pageId = data.newItemId(), - published = true, - title = "Page In Course", - url = URLEncoder.encode("Page In Course", "UTF-8") - ) - data.addItemToModule( - course = course1, - moduleId = module.id, - item = page!! - ) - - // Create a file and add it as a module item - val fileContent = "

A Heading

" - fileCheck = WebViewTextCheck(Locator.ID, "heading1", "A Heading") - - val fileId = data.addFileToCourse( - courseId = course1.id, - displayName = fileName, - fileContent = fileContent, - contentType = "text/html" - ) - val rootFolderId = data.courseRootFolders[course1.id]!!.id - val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } - data.addItemToModule( - course = course1, - moduleId = module.id, - item = fileFolder!! - ) - - // Create an external URL and add it as a module item - data.addItemToModule( - course = course1, - moduleId = module.id, - item = externalUrl - ) - - // Create a quiz and add it as a module item - quiz = data.addQuizToCourse( - course = course1 - ) - - data.addQuestionToQuiz( - course = course1, - quizId = quiz!!.id, - questionName = "Math 1", - questionText = "What is 2 + 5?", - questionType = "multiple_choice_question", - answers = arrayOf( - QuizAnswer(answerText = "7"), - QuizAnswer(answerText = "25"), - QuizAnswer(answerText = "-7") - ) - ) - - data.addQuestionToQuiz( - course = course1, - quizId = quiz!!.id, - questionName = "Math 2", - questionText = "Pi is greater than the square root of 2", - questionType = "true_false_question" - ) - - data.addQuestionToQuiz( - course = course1, - quizId = quiz!!.id, - questionName = "Math 3", - questionText = "Write an essay on why math is so awesome", - questionType = "essay_question" - ) - - data.addItemToModule( - course = course1, - moduleId = module.id, - item = quiz!! - ) - - val ltiTool = data.addLTITool("Google Drive", "http://google.com", course1, 1234L) - data.addItemToModule( - course = course1, - moduleId = module.id, - item = ltiTool!! - ) - // Sign in val student = data.students[0] val token = data.tokenFor(student)!! diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/NotATeacherPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/NotATeacherPageTest.kt index 24a7e27a53..fdabaaf522 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/NotATeacherPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/NotATeacherPageTest.kt @@ -15,6 +15,7 @@ */ package com.instructure.teacher.ui +import com.instructure.canvas.espresso.Stub import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.slowLogInAsStudent import dagger.hilt.android.testing.HiltAndroidTest @@ -25,6 +26,7 @@ class NotATeacherPageTest : TeacherTest() { // Runs live; no MockCanvas @Test + @Stub("Stubbed because of the 'too many login attempts' change on backend. Will be de-stubbed when we find a solution for that.") override fun displaysPageObjects() { slowLogInAsStudent() notATeacherPage.assertPageObjects() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt index 17132fd6d5..4d80738c64 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt @@ -90,6 +90,10 @@ class DiscussionsE2ETest : TeacherTest() { discussionsListPage.assertGroupDisplayed("Pinned") discussionsListPage.assertDiscussionInGroup("Pinned", discussion2.title) + Log.d(STEP_TAG, "Assert that both of the discussions, '${discussion.title}' and '${discussion2.title}' discusssions are displayed.") + discussionsListPage.assertHasDiscussion(newTitle) + discussionsListPage.assertHasDiscussion(discussion2) + Log.d(STEP_TAG,"Navigate to Discussions Details Page by clicking on 'Edit'. Delete the '$newTitle' discussion.") discussionsListPage.clickDiscussion(newTitle) discussionsDetailsPage.openEdit() @@ -127,7 +131,7 @@ class DiscussionsE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert that the '$newDiscussionTitle' discussion is displayed and it is the only one.") discussionsListPage.assertDiscussionCount(1) discussionsListPage.assertHasDiscussion(newDiscussionTitle) - Espresso.pressBack() // need to press back to exit from the search input field + discussionsListPage.searchable.clickOnClearSearchButton() Log.d(STEP_TAG,"Collapse the discussion list and assert that the '$newDiscussionTitle' discussion can NOT be seen.") discussionsListPage.toggleCollapseExpandIcon() @@ -138,11 +142,5 @@ class DiscussionsE2ETest : TeacherTest() { discussionsListPage.toggleCollapseExpandIcon() discussionsListPage.assertDiscussionCount(1) discussionsListPage.assertHasDiscussion(newDiscussionTitle) - - Log.d(STEP_TAG, "Click on the clear search input button (X) on the toolbar. Assert that the default state, so both of the discussions will be displayed.") - discussionsListPage.searchable.clickOnClearSearchButton() - discussionsListPage.assertHasDiscussion(discussion) - discussionsListPage.assertHasDiscussion(discussion2) - discussionsListPage.assertDiscussionCount(2) } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt index 71aab3cde4..87cc6c14c1 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt @@ -175,7 +175,7 @@ class FilesE2ETest: TeacherTest() { fileListPage.assertItemNotDisplayed("unfiled") Log.d(STEP_TAG, "Click on 'Reset' search (cross) icon and assert that all the root level directories and files are displayed (1).") - fileListPage.searchable.clickOnClearSearchButton() + fileListPage.searchable.pressSearchBackButton() fileListPage.assertFileListCount(1) Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt index d8c9ad7b7f..88b00f66ec 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt @@ -95,7 +95,7 @@ class AnnouncementsListPage(val searchable: Searchable) : BasePage() { * @param announcementName: The announcement name string parameter. */ fun assertHasAnnouncement(announcementName: String) { - onView(withText(announcementName)).assertDisplayed() + onView(withId(R.id.discussionTitle) + withText(announcementName)).assertDisplayed() } /** diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CalendarEventPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CalendarEventPage.kt index 6d078ce3c2..63c194c0f8 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CalendarEventPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CalendarEventPage.kt @@ -25,6 +25,8 @@ import androidx.test.espresso.web.webdriver.Locator import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.espresso.assertDisplayed import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.withParent import com.instructure.teacher.R import org.hamcrest.Matchers @@ -56,7 +58,7 @@ class CalendarEventPage : BasePage(R.id.fragmentCalendarEvent) { * @throws AssertionError if the description does not match the expected description. */ fun verifyDescription(description: String) { - Web.onWebView(ViewMatchers.withId(R.id.contentWebView)) + Web.onWebView(ViewMatchers.withId(R.id.contentWebView) + withParent(R.id.calendarEventWebViewWrapper)) .withElement(DriverAtoms.findElement(Locator.ID, "content")) .check(WebViewAssertions.webMatches(DriverAtoms.getText(), Matchers.comparesEqualTo(description))) } From 1d3a67106702b8c1de08a25e2c9480e27f6384cd Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Wed, 23 Aug 2023 11:32:19 +0200 Subject: [PATCH 42/61] [MBL-16976][Student][Teacher] Fixed date picker contrast #2115 refs: MBL-16976 affects: Student, Teacher release note: none --- apps/student/src/main/res/values/styles.xml | 5 +++++ .../main/res/values/themes_canvastheme.xml | 1 + apps/teacher/src/main/res/values/styles.xml | 6 ++++++ .../res/color/calendar_color_selector.xml | 20 +++++++++++++++++++ .../pandautils/src/main/res/values/styles.xml | 4 ++++ 5 files changed, 36 insertions(+) create mode 100644 libs/pandautils/src/main/res/color/calendar_color_selector.xml diff --git a/apps/student/src/main/res/values/styles.xml b/apps/student/src/main/res/values/styles.xml index ab7bd8654a..f7f6d76219 100644 --- a/apps/student/src/main/res/values/styles.xml +++ b/apps/student/src/main/res/values/styles.xml @@ -255,4 +255,9 @@ @color/textDark + + diff --git a/apps/student/src/main/res/values/themes_canvastheme.xml b/apps/student/src/main/res/values/themes_canvastheme.xml index 25ff54c211..286ae42ffc 100755 --- a/apps/student/src/main/res/values/themes_canvastheme.xml +++ b/apps/student/src/main/res/values/themes_canvastheme.xml @@ -43,6 +43,7 @@ @style/ModalDialogStyle @style/AnnotationNoteHinter @color/backgroundLight + @style/DatePickerStyle + + diff --git a/libs/pandautils/src/main/res/color/calendar_color_selector.xml b/libs/pandautils/src/main/res/color/calendar_color_selector.xml new file mode 100644 index 0000000000..1ba3935ed9 --- /dev/null +++ b/libs/pandautils/src/main/res/color/calendar_color_selector.xml @@ -0,0 +1,20 @@ + + + + + \ No newline at end of file diff --git a/libs/pandautils/src/main/res/values/styles.xml b/libs/pandautils/src/main/res/values/styles.xml index 87edd80e33..ea6efca1aa 100644 --- a/libs/pandautils/src/main/res/values/styles.xml +++ b/libs/pandautils/src/main/res/values/styles.xml @@ -219,4 +219,8 @@ @color/white + + From 71e6067faa8235ca1957896430cc1aa3a15e8996 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Wed, 23 Aug 2023 13:05:40 +0200 Subject: [PATCH 43/61] [MBL-16981][All] Inbox input labels always visible (#2108) Test plan: Check the inbox compose screen, the labels should always be visible. refs: MBL-16981 affects: Student, Teacher, Parent release note: Accessibility improvement. --- .../create_conversation_screen.dart | 4 +- .../student/ui/pages/NewMessagePage.kt | 3 +- .../fragment/InboxComposeMessageFragment.kt | 2 +- .../layout/fragment_inbox_compose_message.xml | 70 +++++++++------- .../teacher/fragments/AddMessageFragment.kt | 2 +- .../main/res/layout/fragment_add_message.xml | 83 ++++++++++--------- 6 files changed, 92 insertions(+), 72 deletions(-) diff --git a/apps/flutter_parent/lib/screens/inbox/create_conversation/create_conversation_screen.dart b/apps/flutter_parent/lib/screens/inbox/create_conversation/create_conversation_screen.dart index 911f5db614..1cdbf7b6e8 100644 --- a/apps/flutter_parent/lib/screens/inbox/create_conversation/create_conversation_screen.dart +++ b/apps/flutter_parent/lib/screens/inbox/create_conversation/create_conversation_screen.dart @@ -463,7 +463,7 @@ class _CreateConversationScreenState extends State wit style: Theme.of(context).textTheme.bodyText1, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( - hintText: L10n(context).messageSubjectInputHint, + labelText: L10n(context).messageSubjectInputHint, contentPadding: EdgeInsets.all(16), border: InputBorder.none, ), @@ -484,7 +484,7 @@ class _CreateConversationScreenState extends State wit maxLines: null, style: Theme.of(context).textTheme.bodyText2, decoration: InputDecoration( - hintText: L10n(context).messageBodyInputHint, + labelText: L10n(context).messageBodyInputHint, contentPadding: EdgeInsets.all(16), border: InputBorder.none, ), diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NewMessagePage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NewMessagePage.kt index 5bd887a0cb..ee1737f2da 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NewMessagePage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NewMessagePage.kt @@ -130,7 +130,8 @@ class NewMessagePage : BasePage() { fun setMessage(messageText: String) { Espresso.closeSoftKeyboard() - onView(allOf(withId(R.id.message), hasSibling(withId(R.id.sendIndividualDivider)))) + onView(withId(R.id.messageContainer)).click() + onView(allOf(withId(R.id.message), withAncestor(R.id.messageContainer))) .scrollTo() .typeText(messageText) } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/InboxComposeMessageFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/InboxComposeMessageFragment.kt index 6962b7d493..b4c6148bc8 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/InboxComposeMessageFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/InboxComposeMessageFragment.kt @@ -319,7 +319,7 @@ class InboxComposeMessageFragment : ParentFragment(), FileUploadDialogParent { private fun handleExit() { // Check to see if the user has made any changes - if (binding.editSubject.text.isNotBlank() || binding.message.text.isNotBlank() || attachments.isNotEmpty()) { + if (binding.editSubject.text?.isNotBlank() == true || binding.message.text?.isNotBlank() == true || attachments.isNotEmpty()) { shouldAllowExit = false // Use childFragmentManager so that exiting the compose fragment also dismisses the dialog UnsavedChangesExitDialog.show(childFragmentManager) { diff --git a/apps/student/src/main/res/layout/fragment_inbox_compose_message.xml b/apps/student/src/main/res/layout/fragment_inbox_compose_message.xml index 16b4e10ed5..6a623ffdbb 100644 --- a/apps/student/src/main/res/layout/fragment_inbox_compose_message.xml +++ b/apps/student/src/main/res/layout/fragment_inbox_compose_message.xml @@ -120,22 +120,26 @@ - - + android:layout_height="wrap_content"> + + + - + android:layout_height="wrap_content"> + + + - تم الحذف + تم تحديث الدرجة جارٍ تحميل محتوى Canvas… UnknownDevice @@ -1170,6 +1171,7 @@ يمكنك فتح تفاصيل الإرسال من هنا + الدرجة: %s %s من الدقائق %s دقيقة diff --git a/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml b/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml index e1651e56b6..3d89c5ace5 100644 --- a/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml @@ -432,6 +432,7 @@ Slettet + Vurdering opdateret Indlæser Canvas-indhold… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan åbne afleveringsdetaljerne herfra + Vurdering: %s %s minut %s minutter diff --git a/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml b/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml index 9d91414183..013c458795 100644 --- a/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml +++ b/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml @@ -432,6 +432,7 @@ Deleted + Grade updated Loading Canvas Content… UnknownDevice @@ -1119,6 +1120,7 @@ You can open Submission details from here + Grade: %s %s Minute %s Minutes diff --git a/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml b/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml index 4072036e2e..e860a4f857 100644 --- a/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml +++ b/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml @@ -432,6 +432,7 @@ Deleted + Grade updated Loading Canvas Content… UnknownDevice @@ -1119,6 +1120,7 @@ You can open Submission details from here + Grade: %s %s minute %s Minutes diff --git a/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml b/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml index bd6ae8ec18..3bbbfdbd80 100644 --- a/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml @@ -432,6 +432,7 @@ Slettet + Vurdering oppdatert Laster Canvas-innhold… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan åpne Detaljer om innlevering her + Vurdering: %s %s minutt %s minutter diff --git a/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml b/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml index 44ae3284e8..eb7b807e44 100644 --- a/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml @@ -432,6 +432,7 @@ Borttagen + Bedömning uppdaterat Läser in Canvas-innehåll… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan öppna inlämningsinformationen härifrån + Bedömning: %s %s minut %s minuter diff --git a/libs/pandares/src/main/res/values-b+zh+HK/strings.xml b/libs/pandares/src/main/res/values-b+zh+HK/strings.xml index b6bf168689..ee91fbdcd0 100644 --- a/libs/pandares/src/main/res/values-b+zh+HK/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+HK/strings.xml @@ -426,6 +426,7 @@ 已刪除 + 評分已更新 載入 Canvas 內容… UnknownDevice @@ -1106,6 +1107,7 @@ 您可以從此處開啟提交項目詳細資料 + 評分:%s %s 分鐘 diff --git a/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml b/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml index 815e304d01..9fd0ff9c21 100644 --- a/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml @@ -426,6 +426,7 @@ 已删除 + 评分已更新 正在加载Canvas内容… UnknownDevice @@ -1106,6 +1107,7 @@ 您可以从此处打开提交详情 + 评分:%s %s 分钟 diff --git a/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml b/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml index b6bf168689..ee91fbdcd0 100644 --- a/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml @@ -426,6 +426,7 @@ 已刪除 + 評分已更新 載入 Canvas 內容… UnknownDevice @@ -1106,6 +1107,7 @@ 您可以從此處開啟提交項目詳細資料 + 評分:%s %s 分鐘 diff --git a/libs/pandares/src/main/res/values-ca/strings.xml b/libs/pandares/src/main/res/values-ca/strings.xml index 00d064fb12..013976b360 100644 --- a/libs/pandares/src/main/res/values-ca/strings.xml +++ b/libs/pandares/src/main/res/values-ca/strings.xml @@ -432,6 +432,7 @@ Suprimit + S’ha actualitzat la nota S\'està carregant el contingut de Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Podeu obrir Informació de l’entrega des d\'aquí + Nota: %s %s minut %s minuts diff --git a/libs/pandares/src/main/res/values-cy/strings.xml b/libs/pandares/src/main/res/values-cy/strings.xml index 6bd48540a4..8498f8bf6d 100644 --- a/libs/pandares/src/main/res/values-cy/strings.xml +++ b/libs/pandares/src/main/res/values-cy/strings.xml @@ -432,6 +432,7 @@ Wedi dileu + Gradd wedi’i diweddaru Llwytho Cynnwys Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Gallwch chi agor manylion Cyflwyniad fan hyn + Gradd: %s %s Munud %s Munud diff --git a/libs/pandares/src/main/res/values-da/strings.xml b/libs/pandares/src/main/res/values-da/strings.xml index 5235e2943e..ddc3e5462f 100644 --- a/libs/pandares/src/main/res/values-da/strings.xml +++ b/libs/pandares/src/main/res/values-da/strings.xml @@ -432,6 +432,7 @@ Slettet + Karakter opdateret Indlæser Canvas-indhold… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan åbne afleveringsdetaljerne herfra + Karakter: %s %s minut %s minutter diff --git a/libs/pandares/src/main/res/values-de/strings.xml b/libs/pandares/src/main/res/values-de/strings.xml index 97b9b7ade9..58cda0e850 100644 --- a/libs/pandares/src/main/res/values-de/strings.xml +++ b/libs/pandares/src/main/res/values-de/strings.xml @@ -432,6 +432,7 @@ Gelöscht + Note aktualisiert Canvas-Content laden… UnknownDevice @@ -1119,6 +1120,7 @@ Sie können die Abgabedetails von hier aus öffnen + Note: %s %s Minute %s Minuten diff --git a/libs/pandares/src/main/res/values-en-rAU/strings.xml b/libs/pandares/src/main/res/values-en-rAU/strings.xml index 94d73a8b46..8aa32c6d13 100644 --- a/libs/pandares/src/main/res/values-en-rAU/strings.xml +++ b/libs/pandares/src/main/res/values-en-rAU/strings.xml @@ -432,6 +432,7 @@ Deleted + Mark updated Loading Canvas Content… UnknownDevice @@ -1119,6 +1120,7 @@ You can open Submission details from here + Mark: %s %s Minute %s Minutes diff --git a/libs/pandares/src/main/res/values-en-rCY/strings.xml b/libs/pandares/src/main/res/values-en-rCY/strings.xml index 4072036e2e..e860a4f857 100644 --- a/libs/pandares/src/main/res/values-en-rCY/strings.xml +++ b/libs/pandares/src/main/res/values-en-rCY/strings.xml @@ -432,6 +432,7 @@ Deleted + Grade updated Loading Canvas Content… UnknownDevice @@ -1119,6 +1120,7 @@ You can open Submission details from here + Grade: %s %s minute %s Minutes diff --git a/libs/pandares/src/main/res/values-en-rGB/strings.xml b/libs/pandares/src/main/res/values-en-rGB/strings.xml index 2b1b2629c7..70d3e97250 100644 --- a/libs/pandares/src/main/res/values-en-rGB/strings.xml +++ b/libs/pandares/src/main/res/values-en-rGB/strings.xml @@ -432,6 +432,7 @@ Deleted + Grade updated Loading Canvas Content… UnknownDevice @@ -1119,6 +1120,7 @@ You can open Submission details from here + Grade: %s %s minute %s Minutes diff --git a/libs/pandares/src/main/res/values-es-rES/strings.xml b/libs/pandares/src/main/res/values-es-rES/strings.xml index e8465b9bf7..8cef4f43c9 100644 --- a/libs/pandares/src/main/res/values-es-rES/strings.xml +++ b/libs/pandares/src/main/res/values-es-rES/strings.xml @@ -432,6 +432,7 @@ Eliminado + Nota actualizada Cargando el contenido de Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Desde aquí puedes abrir los detalles de la entrega + Nota: %s %s minuto %s minutos diff --git a/libs/pandares/src/main/res/values-es/strings.xml b/libs/pandares/src/main/res/values-es/strings.xml index 22bfecd1b7..1aaa81bfb7 100644 --- a/libs/pandares/src/main/res/values-es/strings.xml +++ b/libs/pandares/src/main/res/values-es/strings.xml @@ -432,6 +432,7 @@ Eliminado + Calificación actualizada Cargando el contenido de Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Desde aquí puede abrir los detalles de Entrega + Calificación: %s %s minuto %s minutos diff --git a/libs/pandares/src/main/res/values-fi/strings.xml b/libs/pandares/src/main/res/values-fi/strings.xml index d4f361ce9a..ab65479f50 100644 --- a/libs/pandares/src/main/res/values-fi/strings.xml +++ b/libs/pandares/src/main/res/values-fi/strings.xml @@ -432,6 +432,7 @@ Poistettu + Arvosana päivitetty Ladataan Canvas-sisältöä… UnknownDevice @@ -1119,6 +1120,7 @@ Voit avata tehtävän palautustiedot täältä + Arvosana: %s %s Minuutti %s minuuttia diff --git a/libs/pandares/src/main/res/values-fr-rCA/strings.xml b/libs/pandares/src/main/res/values-fr-rCA/strings.xml index 63e5e000cd..dd583f2abc 100644 --- a/libs/pandares/src/main/res/values-fr-rCA/strings.xml +++ b/libs/pandares/src/main/res/values-fr-rCA/strings.xml @@ -432,6 +432,7 @@ Supprimé + Note mise à jour Chargement du contenu de Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Vous pouvez ouvrir les détails de l’envoi à partir d’ici + Note : %s %s Minute %s Minutes diff --git a/libs/pandares/src/main/res/values-fr/strings.xml b/libs/pandares/src/main/res/values-fr/strings.xml index a566c9618a..adaf919f50 100644 --- a/libs/pandares/src/main/res/values-fr/strings.xml +++ b/libs/pandares/src/main/res/values-fr/strings.xml @@ -432,6 +432,7 @@ Supprimé + Note mise à jour Chargement du contenu Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Vous pouvez ouvrir les Détails de soumission à partir d\'ici + Note : %s %s Minute %s Minutes diff --git a/libs/pandares/src/main/res/values-ht/strings.xml b/libs/pandares/src/main/res/values-ht/strings.xml index bad97880f0..4d9e1e3de6 100644 --- a/libs/pandares/src/main/res/values-ht/strings.xml +++ b/libs/pandares/src/main/res/values-ht/strings.xml @@ -432,6 +432,7 @@ Efase + Klas aktyalize Chajman Kontni Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Ou ka ouvri detay Soumisyon yo la a + Klas: %s %s Minit %s Minit diff --git a/libs/pandares/src/main/res/values-is/strings.xml b/libs/pandares/src/main/res/values-is/strings.xml index 11335d2433..71498f4b73 100644 --- a/libs/pandares/src/main/res/values-is/strings.xml +++ b/libs/pandares/src/main/res/values-is/strings.xml @@ -432,6 +432,7 @@ Eytt + Einkunn uppfærð Sæki Canvas efni… UnknownDevice @@ -1119,6 +1120,7 @@ Þú getur opnað upplýsingar um skil héðan + Einkunn: %s %s Mínúta %s mínútur diff --git a/libs/pandares/src/main/res/values-it/strings.xml b/libs/pandares/src/main/res/values-it/strings.xml index 95e35d513b..1ebe6ca67a 100644 --- a/libs/pandares/src/main/res/values-it/strings.xml +++ b/libs/pandares/src/main/res/values-it/strings.xml @@ -432,6 +432,7 @@ Eliminato + Voto aggiornato Caricamento dei contenuti Contenuto… UnknownDevice @@ -1119,6 +1120,7 @@ Puoi aprire i dettagli consegna da qui + Voto: %s %s minuto %s minuti diff --git a/libs/pandares/src/main/res/values-ja/strings.xml b/libs/pandares/src/main/res/values-ja/strings.xml index 44d2184638..0b26bf4991 100644 --- a/libs/pandares/src/main/res/values-ja/strings.xml +++ b/libs/pandares/src/main/res/values-ja/strings.xml @@ -426,6 +426,7 @@ 削除されました + 評定を更新しました Canvas コンテンツ…を読み込み中 UnknownDevice @@ -1106,6 +1107,7 @@ 「提出」の詳細はここで開くことができます + 評定:%s %s 分 diff --git a/libs/pandares/src/main/res/values-mi/strings.xml b/libs/pandares/src/main/res/values-mi/strings.xml index 31a742b7fb..3ce51d36f9 100644 --- a/libs/pandares/src/main/res/values-mi/strings.xml +++ b/libs/pandares/src/main/res/values-mi/strings.xml @@ -432,6 +432,7 @@ mukua + Koeke kua whakahoutia E uta ana Canvas Ihirangi… UnknownDevice @@ -1119,6 +1120,7 @@ Mai i konei, ka kite koe i nga Taipitopito Tukunga. + Kōeke: %s %s Meneti %s meneti diff --git a/libs/pandares/src/main/res/values-ms/strings.xml b/libs/pandares/src/main/res/values-ms/strings.xml index 22552f920e..bce19c3d3a 100644 --- a/libs/pandares/src/main/res/values-ms/strings.xml +++ b/libs/pandares/src/main/res/values-ms/strings.xml @@ -432,6 +432,7 @@ Telah Dipadamkan + Gred dikemas kini Memuatkan Kandungan Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Anda boleh membuka butiran Serahan di sini + Gred: %s %s Minit %s Minit diff --git a/libs/pandares/src/main/res/values-nb/strings.xml b/libs/pandares/src/main/res/values-nb/strings.xml index fc43bf719c..74ed00f0d0 100644 --- a/libs/pandares/src/main/res/values-nb/strings.xml +++ b/libs/pandares/src/main/res/values-nb/strings.xml @@ -432,6 +432,7 @@ Slettet + Vurdering oppdatert Laster Canvas-innhold… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan åpne Detaljer om innlevering her + Vurdering: %s %s minutt %s minutter diff --git a/libs/pandares/src/main/res/values-nl/strings.xml b/libs/pandares/src/main/res/values-nl/strings.xml index e5e50a7c83..0b32e79400 100644 --- a/libs/pandares/src/main/res/values-nl/strings.xml +++ b/libs/pandares/src/main/res/values-nl/strings.xml @@ -432,6 +432,7 @@ Verwijderd + Cijfer bijgewerkt Canvas Content aan het uploaden… UnknownDevice @@ -1119,6 +1120,7 @@ U kunt inleverdetails hier openen + Cijfer: %s %s minuut %s minuten diff --git a/libs/pandares/src/main/res/values-pl/strings.xml b/libs/pandares/src/main/res/values-pl/strings.xml index f40e84f984..a9f3d91551 100644 --- a/libs/pandares/src/main/res/values-pl/strings.xml +++ b/libs/pandares/src/main/res/values-pl/strings.xml @@ -444,6 +444,7 @@ Usunięto + Zaktualizowano ocenę Ładowanie zawartości Canvas… UnknownDevice @@ -1145,6 +1146,7 @@ Tutaj można otworzyć szczegóły przesyłki + Ocena: %s %s min %s min diff --git a/libs/pandares/src/main/res/values-pt-rBR/strings.xml b/libs/pandares/src/main/res/values-pt-rBR/strings.xml index ce7e963aba..dc5f3aec5c 100644 --- a/libs/pandares/src/main/res/values-pt-rBR/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rBR/strings.xml @@ -432,6 +432,7 @@ Excluído + Nota atualizada Carregando conteúdo do Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Você pode abrir os detalhes do Envio aqui + Nota: %s %s Minuto %s Minutos diff --git a/libs/pandares/src/main/res/values-pt-rPT/strings.xml b/libs/pandares/src/main/res/values-pt-rPT/strings.xml index 3c73a4d156..1bf5144981 100644 --- a/libs/pandares/src/main/res/values-pt-rPT/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rPT/strings.xml @@ -432,6 +432,7 @@ Eliminado + Nota atualizada A carregar o conteúdo da tela… UnknownDevice @@ -1119,6 +1120,7 @@ Podes abrir detalhes de Submissão a partir daqui + Nota: %s %s minuto %s minutos diff --git a/libs/pandares/src/main/res/values-ru/strings.xml b/libs/pandares/src/main/res/values-ru/strings.xml index 311d8519e6..9c7d3ded27 100644 --- a/libs/pandares/src/main/res/values-ru/strings.xml +++ b/libs/pandares/src/main/res/values-ru/strings.xml @@ -444,6 +444,7 @@ Удалено + Оценка обновлена Загрузка контента Canvas… UnknownDevice @@ -1145,6 +1146,7 @@ Вы можете открыть информацию об отправке здесь + Оценка: %s %s минута %s минут diff --git a/libs/pandares/src/main/res/values-sl/strings.xml b/libs/pandares/src/main/res/values-sl/strings.xml index 3dd4497706..b77f4e12fc 100644 --- a/libs/pandares/src/main/res/values-sl/strings.xml +++ b/libs/pandares/src/main/res/values-sl/strings.xml @@ -432,6 +432,7 @@ Odstranjeno + Ocena je posodobljena Nalaganje vsebine sistema Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Tukaj lahko odprete podrobnosti o oddaji + Ocena: %s %s minuta %s minut diff --git a/libs/pandares/src/main/res/values-sv/strings.xml b/libs/pandares/src/main/res/values-sv/strings.xml index b923d00664..fc1aee1a9d 100644 --- a/libs/pandares/src/main/res/values-sv/strings.xml +++ b/libs/pandares/src/main/res/values-sv/strings.xml @@ -432,6 +432,7 @@ Borttagen + Omdöme uppdaterat Läser in Canvas-innehåll… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan öppna inlämningsinformationen härifrån + Omdöme: %s %s minut %s minuter diff --git a/libs/pandares/src/main/res/values-th/strings.xml b/libs/pandares/src/main/res/values-th/strings.xml index a0a534be86..e9871f3baf 100644 --- a/libs/pandares/src/main/res/values-th/strings.xml +++ b/libs/pandares/src/main/res/values-th/strings.xml @@ -432,6 +432,7 @@ ลบแล้ว + อัพเดตเกรดแล้ว กำลังโหลดเนื้อหา Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ คุณสามารถเปิดรายละเอียดผลงานจัดส่ง (Submission details) ได้จากที่นี่ + เกรด: %s %s นาที %s นาที diff --git a/libs/pandares/src/main/res/values-vi/strings.xml b/libs/pandares/src/main/res/values-vi/strings.xml index db8ee483c2..bcf91a4a53 100644 --- a/libs/pandares/src/main/res/values-vi/strings.xml +++ b/libs/pandares/src/main/res/values-vi/strings.xml @@ -432,6 +432,7 @@ Đã xóa + Đã cập nhật lớp Đang Tải Nội Dung Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Bạn có thể mở chi tiết Bài Nộp từ đây + Lớp: %s %s Phút %s Phút diff --git a/libs/pandares/src/main/res/values-zh/strings.xml b/libs/pandares/src/main/res/values-zh/strings.xml index 815e304d01..9fd0ff9c21 100644 --- a/libs/pandares/src/main/res/values-zh/strings.xml +++ b/libs/pandares/src/main/res/values-zh/strings.xml @@ -426,6 +426,7 @@ 已删除 + 评分已更新 正在加载Canvas内容… UnknownDevice @@ -1106,6 +1107,7 @@ 您可以从此处打开提交详情 + 评分:%s %s 分钟 From 9d18a6c9a6ca517bff6ca338f679bc7f81e3eb53 Mon Sep 17 00:00:00 2001 From: "kristof.nemere" Date: Wed, 30 Aug 2023 13:44:38 +0200 Subject: [PATCH 53/61] Version bump --- apps/flutter_parent/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flutter_parent/pubspec.yaml b/apps/flutter_parent/pubspec.yaml index c87f6a3c72..d43bf36c23 100644 --- a/apps/flutter_parent/pubspec.yaml +++ b/apps/flutter_parent/pubspec.yaml @@ -25,7 +25,7 @@ description: Canvas Parent # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.8.0+46 +version: 3.8.1+47 module: androidX: true From 233b5a52b37528d1a327b1b8d8f6846f665dc2f6 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Wed, 30 Aug 2023 13:54:13 +0200 Subject: [PATCH 54/61] [MBL-16965][Student] - Separate announcement list page from discussion list page (#2125) --- .../student/ui/e2e/AnnouncementsE2ETest.kt | 42 +++++++++---------- .../student/ui/pages/AnnouncementListPage.kt | 4 +- .../student/ui/pages/DiscussionListPage.kt | 2 +- .../student/ui/utils/StudentTest.kt | 2 +- .../teacher/ui/e2e/AnnouncementsE2ETest.kt | 23 +++++----- .../teacher/ui/e2e/DiscussionsE2ETest.kt | 10 ++--- .../teacher/ui/pages/AnnouncementsListPage.kt | 16 +++---- ...Page.kt => EditAnnouncementDetailsPage.kt} | 16 ++----- .../ui/pages/EditDiscussionsDetailsPage.kt | 6 +-- .../teacher/ui/utils/TeacherTest.kt | 4 +- 10 files changed, 60 insertions(+), 65 deletions(-) rename apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/{EditAnnouncementPage.kt => EditAnnouncementDetailsPage.kt} (82%) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt index 65b44544cd..755499d291 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt @@ -63,13 +63,13 @@ class AnnouncementsE2ETest : StudentTest() { courseBrowserPage.selectAnnouncements() Log.d(STEP_TAG,"Assert that ${announcement.title} announcement is displayed.") - discussionListPage.assertTopicDisplayed(announcement.title) + announcementListPage.assertTopicDisplayed(announcement.title) Log.d(STEP_TAG, "Assert that ${lockedAnnouncement.title} announcement is really locked so that the 'locked' icon is displayed.") - discussionListPage.assertAnnouncementLocked(lockedAnnouncement.title) + announcementListPage.assertAnnouncementLocked(lockedAnnouncement.title) Log.d(STEP_TAG, "Select ${lockedAnnouncement.title} announcement and assert if we are landing on the Discussion Details Page.") - discussionListPage.selectTopic(lockedAnnouncement.title) + announcementListPage.selectTopic(lockedAnnouncement.title) discussionDetailsPage.assertTitleText(lockedAnnouncement.title) Log.d(STEP_TAG, "Assert that the 'Reply' button is not available on a locked announcement. Navigate back to Announcement List Page.") @@ -77,7 +77,7 @@ class AnnouncementsE2ETest : StudentTest() { Espresso.pressBack() Log.d(STEP_TAG,"Select ${announcement.title} announcement and assert if we are landing on the Discussion Details Page.") - discussionListPage.selectTopic(announcement.title) + announcementListPage.selectTopic(announcement.title) discussionDetailsPage.assertTitleText(announcement.title) val replyMessage = "Reply text" @@ -92,36 +92,36 @@ class AnnouncementsE2ETest : StudentTest() { Log.d(STEP_TAG,"Click on Search button and type ${announcement.title} to the search input field.") Espresso.pressBack() - discussionListPage.searchable.clickOnSearchButton() - discussionListPage.searchable.typeToSearchBar(announcement.title) + announcementListPage.searchable.clickOnSearchButton() + announcementListPage.searchable.typeToSearchBar(announcement.title) Log.d(STEP_TAG,"Assert that only the matching announcement is displayed on the Discussion List Page.") - discussionListPage.pullToUpdate() - discussionListPage.assertTopicDisplayed(announcement.title) - discussionListPage.assertTopicNotDisplayed(lockedAnnouncement.title) + announcementListPage.pullToUpdate() + announcementListPage.assertTopicDisplayed(announcement.title) + announcementListPage.assertTopicNotDisplayed(lockedAnnouncement.title) Log.d(STEP_TAG,"Clear search input field value and assert if all the announcements are displayed again on the Discussion List Page.") - discussionListPage.searchable.clickOnClearSearchButton() - discussionListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) - discussionListPage.assertTopicDisplayed(announcement.title) + announcementListPage.searchable.clickOnClearSearchButton() + announcementListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) + announcementListPage.assertTopicDisplayed(announcement.title) Log.d(STEP_TAG,"Type a search value to the search input field which does not much with any of the existing announcements.") - discussionListPage.searchable.typeToSearchBar("Non existing announcement title") + announcementListPage.searchable.typeToSearchBar("Non existing announcement title") sleep(3000) //We need this wait here to let make sure the search process has finished. Log.d(STEP_TAG,"Assert that the empty view is displayed and none of the announcements are appearing on the page.") - discussionListPage.assertEmpty() - discussionListPage.assertTopicNotDisplayed(announcement.title) - discussionListPage.assertTopicNotDisplayed(lockedAnnouncement.title) + announcementListPage.assertEmpty() + announcementListPage.assertTopicNotDisplayed(announcement.title) + announcementListPage.assertTopicNotDisplayed(lockedAnnouncement.title) Log.d(STEP_TAG,"Clear search input field value and assert if all the announcements are displayed again on the Discussion List Page.") - discussionListPage.searchable.clickOnClearSearchButton() - discussionListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) - discussionListPage.assertTopicDisplayed(announcement.title) + announcementListPage.searchable.clickOnClearSearchButton() + announcementListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) + announcementListPage.assertTopicDisplayed(announcement.title) Log.d(STEP_TAG,"Refresh the page and assert that after refresh, still all the announcements are displayed.") refresh() - discussionListPage.assertTopicDisplayed(announcement.title) - discussionListPage.assertTopicDisplayed(lockedAnnouncement.title) + announcementListPage.assertTopicDisplayed(announcement.title) + announcementListPage.assertTopicDisplayed(lockedAnnouncement.title) } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt index d4986a1d9e..26c6b09593 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt @@ -17,16 +17,16 @@ package com.instructure.student.ui.pages import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed import com.instructure.espresso.matchers.WaitForViewMatcher -import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.plus import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText import com.instructure.student.R -class AnnouncementListPage() : BasePage(R.id.discussionListPage) { +class AnnouncementListPage(searchable: Searchable) : DiscussionListPage(searchable) { fun assertToolbarTitle() { WaitForViewMatcher.waitForView(withParent(R.id.discussionListToolbar) + withText(R.string.announcements)).assertDisplayed() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt index 3e6bebfce7..28f125e1a9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt @@ -49,7 +49,7 @@ import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.anyOf import org.hamcrest.Matchers.containsString -class DiscussionListPage(val searchable: Searchable) : BasePage(R.id.discussionListPage) { +open class DiscussionListPage(val searchable: Searchable) : BasePage(R.id.discussionListPage) { private val createNewDiscussion by OnViewWithId(R.id.createNewDiscussion) private val announcementsRecyclerView by OnViewWithId(R.id.discussionRecyclerView) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt index 2285f6c0ce..6000d1db3b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt @@ -143,7 +143,7 @@ abstract class StudentTest : CanvasTest() { * Required for auto complete of page objects within tests */ val annotationCommentListPage = AnnotationCommentListPage() - val announcementListPage = AnnouncementListPage() + val announcementListPage = AnnouncementListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val assignmentDetailsPage = AssignmentDetailsPage() val assignmentListPage = AssignmentListPage(Searchable(R.id.search, R.id.search_src_text)) val bookmarkPage = BookmarkPage() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt index fb91c72a4a..30257cff90 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt @@ -41,6 +41,9 @@ class AnnouncementsE2ETest : TeacherTest() { override fun enableAndConfigureAccessibilityChecks() = Unit + //Because of naming conventions, we are using 'announcementDetailsPage' naming in this class to make the code more readable and straightforward. + private val announcementDetailsPage = discussionsDetailsPage + /** * Test announcements e2e * @@ -82,10 +85,10 @@ class AnnouncementsE2ETest : TeacherTest() { announcementsListPage.assertSearchResultCount(2) Log.d(STEP_TAG,"Edit ${announcement.title} announcement's name to 'Haha'. Save the modifications.") - announcementsListPage.clickDiscussion(announcement) - editAnnouncementPage.openEdit() - editAnnouncementPage.editAnnouncementName("Haha") - editAnnouncementPage.saveEditAnnouncement() + announcementsListPage.clickAnnouncement(announcement) + announcementDetailsPage.openEdit() + editAnnouncementDetailsPage.editAnnouncementTitle("Haha") + editAnnouncementDetailsPage.saveAnnouncement() Log.d(STEP_TAG,"Navigate back to the Announcements Page. Refresh the page and assert that the announcement name has been changed to 'Haha'.") Espresso.pressBack() @@ -93,14 +96,14 @@ class AnnouncementsE2ETest : TeacherTest() { announcementsListPage.assertHasAnnouncement("Haha") Log.d(STEP_TAG,"Delete the 'Haha' titled announcement.") - announcementsListPage.clickDiscussion("Haha") - editAnnouncementPage.openEdit() - editAnnouncementPage.deleteAnnouncement() + announcementsListPage.clickAnnouncement("Haha") + announcementDetailsPage.openEdit() + editAnnouncementDetailsPage.deleteAnnouncement() Log.d(STEP_TAG, "") - announcementsListPage.clickDiscussion(announcement2.title) - editAnnouncementPage.openEdit() - editAnnouncementPage.deleteAnnouncement() + announcementsListPage.clickAnnouncement(announcement2.title) + announcementDetailsPage.openEdit() + editAnnouncementDetailsPage.deleteAnnouncement() Log.d(STEP_TAG,"Refresh the Announcements Page and assert that there is no announcement displayed. Assert that empty view is displayed.") announcementsListPage.refresh() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt index 4d80738c64..7fed199a13 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt @@ -66,8 +66,8 @@ class DiscussionsE2ETest : TeacherTest() { val newTitle = "New Discussion" Log.d(STEP_TAG,"Edit the discussion's title to: '$newTitle'. Click on 'Save'.") - editDiscussionsDetailsPage.editTitle(newTitle) - editDiscussionsDetailsPage.clickSave() + editDiscussionsDetailsPage.editDiscussionTitle(newTitle) + editDiscussionsDetailsPage.saveDiscussion() Log.d(STEP_TAG,"Refresh the page. Assert that the discussion's name has been changed to '$newTitle' and it is published.") discussionsDetailsPage.refresh() @@ -77,7 +77,7 @@ class DiscussionsE2ETest : TeacherTest() { Log.d(STEP_TAG,"Navigate to Discussions Details Page by clicking on 'Edit'. Unpublish the '$newTitle' discussion and click on 'Save'.") discussionsDetailsPage.openEdit() editDiscussionsDetailsPage.togglePublished() - editDiscussionsDetailsPage.clickSave() + editDiscussionsDetailsPage.saveDiscussion() Log.d(STEP_TAG,"Refresh the page. Assert that the '$newTitle' discussion has been unpublished.") discussionsDetailsPage.refresh() @@ -111,8 +111,8 @@ class DiscussionsE2ETest : TeacherTest() { val newDiscussionTitle = "Test Discussion Mobile UI" Log.d(STEP_TAG,"Set '$newDiscussionTitle' as the discussion's title and set some description as well.") - editDiscussionsDetailsPage.editTitle(newDiscussionTitle) - editDiscussionsDetailsPage.editDescription("Mobile UI Discussion description") + editDiscussionsDetailsPage.editDiscussionTitle(newDiscussionTitle) + editDiscussionsDetailsPage.editDiscussionDescription("Mobile UI Discussion description") Log.d(STEP_TAG,"Toggle Publish checkbox and save the page.") editDiscussionsDetailsPage.togglePublished() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt index 88b00f66ec..932b13e31f 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AnnouncementsListPage.kt @@ -42,9 +42,9 @@ import com.instructure.teacher.R import com.instructure.teacher.ui.utils.TypeInRCETextEditor /** - * Announcements list page + * Announcements list page. * - * @constructor Create empty Announcements list page + * @constructor Create empty Announcements list page. */ class AnnouncementsListPage(val searchable: Searchable) : BasePage() { @@ -56,19 +56,19 @@ class AnnouncementsListPage(val searchable: Searchable) : BasePage() { /** * Click on the discussion given in parameter. * - * @param discussion: The DiscussionApiModel parameter. + * @param announcement: The DiscussionApiModel parameter. */ - fun clickDiscussion(discussion: DiscussionApiModel) { - clickDiscussion(discussion.title) + fun clickAnnouncement(announcement: DiscussionApiModel) { + clickAnnouncement(announcement.title) } /** * Click on the discussion with the given title in parameter. * - * @param discussionTitle: The discussion title parameter string. + * @param announcementTitle: The discussion title parameter string. */ - fun clickDiscussion(discussionTitle: String) { - waitForViewWithText(discussionTitle).click() + fun clickAnnouncement(announcementTitle: String) { + waitForViewWithText(announcementTitle).click() } /** diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAnnouncementPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAnnouncementDetailsPage.kt similarity index 82% rename from apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAnnouncementPage.kt rename to apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAnnouncementDetailsPage.kt index 2bb5119eda..9415744a68 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAnnouncementPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditAnnouncementDetailsPage.kt @@ -20,37 +20,29 @@ import androidx.test.espresso.Espresso import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView -import com.instructure.espresso.page.waitForView import com.instructure.espresso.page.withId import com.instructure.espresso.replaceText import com.instructure.espresso.scrollTo import com.instructure.teacher.R /** - * Represents the Edit Announcement page. + * Represents the Edit Announcement Page. */ -class EditAnnouncementPage : BasePage() { - - /** - * Opens the edit mode for the announcement. - */ - fun openEdit() { - waitForView(withId(R.id.menu_edit)).click() - } +class EditAnnouncementDetailsPage : BasePage() { /** * Edits the name of the announcement with the specified [newName]. * * @param newName The new name for the announcement. */ - fun editAnnouncementName(newName: String) { + fun editAnnouncementTitle(newName: String) { onView(withId(R.id.announcementNameEditText)).replaceText(newName) } /** * Saves the edited announcement. */ - fun saveEditAnnouncement() { + fun saveAnnouncement() { onView(withId(R.id.menuSaveAnnouncement)).click() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDiscussionsDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDiscussionsDetailsPage.kt index fa67b64638..e89ad6cc21 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDiscussionsDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditDiscussionsDetailsPage.kt @@ -42,7 +42,7 @@ class EditDiscussionsDetailsPage : BasePage() { * * @param newTitle The new title of the discussion. */ - fun editTitle(newTitle: String) { + fun editDiscussionTitle(newTitle: String) { onView(withId(R.id.editDiscussionName)).replaceText(newTitle) Espresso.closeSoftKeyboard() } @@ -65,7 +65,7 @@ class EditDiscussionsDetailsPage : BasePage() { /** * Clicks the save button. This method is used when editing an existing discussion. */ - fun clickSave() { + fun saveDiscussion() { onView(withId(R.id.menuSave)).click() } @@ -81,7 +81,7 @@ class EditDiscussionsDetailsPage : BasePage() { * * @param newDescription The new description of the discussion. */ - fun editDescription(newDescription: String) { + fun editDiscussionDescription(newDescription: String) { contentRceView.perform(TypeInRCETextEditor(newDescription)) } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt index 7dafc5a3ab..a79f75ea44 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt @@ -46,7 +46,7 @@ import com.instructure.teacher.ui.pages.CourseSettingsPage import com.instructure.teacher.ui.pages.DashboardPage import com.instructure.teacher.ui.pages.DiscussionsDetailsPage import com.instructure.teacher.ui.pages.DiscussionsListPage -import com.instructure.teacher.ui.pages.EditAnnouncementPage +import com.instructure.teacher.ui.pages.EditAnnouncementDetailsPage import com.instructure.teacher.ui.pages.EditAssignmentDetailsPage import com.instructure.teacher.ui.pages.EditDashboardPage import com.instructure.teacher.ui.pages.EditDiscussionsDetailsPage @@ -147,7 +147,7 @@ abstract class TeacherTest : CanvasTest() { val editProfileSettingsPage = EditProfileSettingsPage() val discussionsDetailsPage = DiscussionsDetailsPage() val discussionsListPage = DiscussionsListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) - val editAnnouncementPage = EditAnnouncementPage() + val editAnnouncementDetailsPage = EditAnnouncementDetailsPage() val editAssignmentDetailsPage = EditAssignmentDetailsPage() val editDiscussionsDetailsPage = EditDiscussionsDetailsPage() val editPageDetailsPage = EditPageDetailsPage() From c007dcf3dd9fd2344e97c70aa767e1b788efe033 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Thu, 31 Aug 2023 12:02:59 +0200 Subject: [PATCH 55/61] Fix the only breaking Student multi-api level test (#2132) --- .../student/ui/pages/LeftSideNavigationDrawerPage.kt | 2 +- .../kotlin/com/instructure/canvas/espresso/CanvasTest.kt | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt index d1a3d9a3ca..9842cbf0bc 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt @@ -137,7 +137,7 @@ class LeftSideNavigationDrawerPage: BasePage() { settings.assertDisplayed() - if(CanvasTest.isLandscapeDevice()) onView(withId(R.id.navigationDrawer)).swipeUp() + if(CanvasTest.isLandscapeDevice() || CanvasTest.isLowResDevice()) onView(withId(R.id.navigationDrawer)).swipeUp() changeUser.assertDisplayed() logoutButton.assertDisplayed() diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt index ae7ed73795..185d7aa675 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt @@ -296,11 +296,6 @@ abstract class CanvasTest : InstructureTestingContract { } } - // Does the test device have particularly low screen resolution? - fun isLowResDevice() : Boolean { - return activityRule.activity.resources.displayMetrics.densityDpi < DisplayMetrics.DENSITY_HIGH - } - fun isTabletDevice(): Boolean { val metrics = activityRule.activity.resources.displayMetrics @@ -482,6 +477,10 @@ abstract class CanvasTest : InstructureTestingContract { return getDeviceOrientation(ApplicationProvider.getApplicationContext()) == Configuration.ORIENTATION_PORTRAIT } + // Does the test device have particularly low screen resolution? + fun isLowResDevice() : Boolean { + return ApplicationProvider.getApplicationContext().resources.displayMetrics.densityDpi < DisplayMetrics.DENSITY_HIGH + } } } From d4c8bffc623332b52e742c90fb8b80e2f4229cf3 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:02:48 +0200 Subject: [PATCH 56/61] Implement search logics to Teacher AssignmentE2E test. (#2128) Refactor DoesNotExistAssertion to handle timeout in seconds. refs: MBL-16951 affects: Teacher release note: none --- .../student/ui/pages/PageListPage.kt | 4 ++-- .../teacher/ui/e2e/AssignmentE2ETest.kt | 14 +++++++++++++- .../teacher/ui/pages/AssignmentListPage.kt | 17 +++++++++++++++-- .../instructure/teacher/ui/utils/TeacherTest.kt | 2 +- .../espresso/CustomViewAssertions.kt | 10 +++++----- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt index 71dea6a596..430678fc1f 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt @@ -102,10 +102,10 @@ class PageListPage(val searchable: Searchable) : BasePage(R.id.pageListPage) { fun assertPageNotDisplayed(page: PageApiModel) { // Check for front page - onView(allOf(withId(R.id.homeSubLabel), withText(page.title))).check(DoesNotExistAssertion(10000L)) + onView(allOf(withId(R.id.homeSubLabel), withText(page.title))).check(DoesNotExistAssertion(10)) // Check for regular page - onView(allOf(withId(R.id.title), withText(page.title))).check(DoesNotExistAssertion(10000L)) + onView(allOf(withId(R.id.title), withText(page.title))).check(DoesNotExistAssertion(10)) } fun assertPageListItemCount(expectedCount: Int) { diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt index 82593033cd..95ff85feea 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt @@ -196,6 +196,19 @@ class AssignmentE2ETest : TeacherTest() { assignmentListPage.assertHasAssignment(assignment[0]) assignmentListPage.assertNeedsGradingCountOfAssignment(assignment[0].name, 1) + Log.d(STEP_TAG,"Click on Search button and type '${quizAssignment[0].name}' to the search input field.") + assignmentListPage.searchable.clickOnSearchButton() + assignmentListPage.searchable.typeToSearchBar(quizAssignment[0].name) + + Log.d(STEP_TAG, "Assert that the '${quizAssignment[0].name}' quiz assignment is the only one which is displayed because it matches the search text.") + assignmentListPage.assertHasAssignment(quizAssignment[0]) + assignmentListPage.assertAssignmentNotDisplayed(assignment[0]) + + Log.d(STEP_TAG,"Clear search input field value and assert if both of the assignment are displayed again on the Assignment List Page.") + assignmentListPage.searchable.clickOnClearSearchButton() + assignmentListPage.assertHasAssignment(assignment[0]) + assignmentListPage.assertHasAssignment(quizAssignment[0]) + val newAssignmentName = "New Assignment Name" Log.d(STEP_TAG,"Edit ${assignment[0].name} assignment's name to: $newAssignmentName.") assignmentListPage.clickAssignment(assignment[0]) @@ -336,7 +349,6 @@ class AssignmentE2ETest : TeacherTest() { Espresso.pressBack() speedGraderCommentsPage.clickOnVideoComment() speedGraderCommentsPage.assertMediaCommentPreviewDisplayed() - } @E2E diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt index 99943b489e..0bf5665ac8 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentListPage.kt @@ -22,15 +22,19 @@ import androidx.test.espresso.matcher.ViewMatchers.withChild import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import com.instructure.canvasapi2.models.Assignment import com.instructure.dataseeding.model.AssignmentApiModel +import com.instructure.espresso.DoesNotExistAssertion import com.instructure.espresso.OnViewWithId import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.Searchable import com.instructure.espresso.WaitForViewWithId import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView import com.instructure.espresso.page.waitForViewWithText +import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withId import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText @@ -45,7 +49,7 @@ import org.hamcrest.CoreMatchers.allOf * * @constructor Creates an instance of the AssignmentListPage. */ -class AssignmentListPage : BasePage() { +class AssignmentListPage(val searchable: Searchable) : BasePage() { private val assignmentListToolbar by OnViewWithId(R.id.assignmentListToolbar) private val assignmentRecyclerView by OnViewWithId(R.id.assignmentRecyclerView) @@ -97,6 +101,15 @@ class AssignmentListPage : BasePage() { assertAssignmentName(assignment.name) } + /** + * Asserts that the given assignment is NOT present in the list. + * + * @param assignment The assignment to check. + */ + fun assertAssignmentNotDisplayed(assignment: AssignmentApiModel) { + onView(withText(assignment.name)).check(DoesNotExistAssertion(10)) + } + /** * Asserts that grading periods are present. */ @@ -137,7 +150,7 @@ class AssignmentListPage : BasePage() { } private fun assertAssignmentName(assignmentName: String) { - waitForViewWithText(assignmentName).assertDisplayed() + waitForView(withText(assignmentName) + withAncestor(R.id.assignmentLayout)).assertDisplayed() } /** diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt index a79f75ea44..dd1f97d460 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt @@ -127,7 +127,7 @@ abstract class TeacherTest : CanvasTest() { val assigneeListPage = AssigneeListPage() val assignmentDetailsPage = AssignmentDetailsPage() val assignmentDueDatesPage = AssignmentDueDatesPage() - val assignmentListPage = AssignmentListPage() + val assignmentListPage = AssignmentListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val assignmentSubmissionListPage = AssignmentSubmissionListPage() val postSettingsPage = PostSettingsPage() val calendarEventPage = CalendarEventPage() diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt index a9c27b5bc9..92a6c1f62d 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt @@ -77,20 +77,20 @@ class NotificationBadgeAssertion(@IdRes private val menuItemId: Int, private val } } -class DoesNotExistAssertion(private val timeout: Long, private val pollInterval: Long = 500L) : ViewAssertion { +class DoesNotExistAssertion(private val timeoutInSeconds: Long, private val pollIntervalInSeconds: Long = 1L) : ViewAssertion { override fun check(view: View?, noViewFoundException: NoMatchingViewException?) { var elapsedTime = 0L - while (elapsedTime < timeout) { + while (elapsedTime < timeoutInSeconds * 1000) { try { doesNotExist() return } catch (e: AssertionFailedError) { - Thread.sleep(pollInterval) - elapsedTime += pollInterval + Thread.sleep(pollIntervalInSeconds * 1000) + elapsedTime += (pollIntervalInSeconds * 1000) } } - throw AssertionError("View still exists after $timeout milliseconds.") + throw AssertionError("View still exists after $timeoutInSeconds seconds.") } } \ No newline at end of file From 016b944a6121a0235b1ab34a67954555093260a1 Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Fri, 1 Sep 2023 10:51:57 +0200 Subject: [PATCH 57/61] [MBL-17011][Student] Documents embedded in RCE content don't load refs: MBL-17011 affects: Student release note: none --- .../student/fragment/CourseModuleProgressionFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt index ef94c02f2d..88225b69ce 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt @@ -723,7 +723,7 @@ class CourseModuleProgressionFragment : ParentFragment(), Bookmarkable { val moduleItemAsset = ModuleItemAsset.fromAssetType(assetType) if (moduleItemAsset != ModuleItemAsset.MODULE_ITEM) { val newRoute = route.copy(secondaryClass = moduleItemAsset.routeClass, removePreviousScreen = true) - RouteMatcher.route(requireContext(), newRoute) + RouteMatcher.route(requireActivity(), newRoute) return@tryWeave } } From 5deacbf8d4d0dcd52d2a7fe09aade60dd880f623 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Mon, 4 Sep 2023 13:34:51 +0200 Subject: [PATCH 58/61] Updated version. --- apps/student/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/student/build.gradle b/apps/student/build.gradle index bc4c0c3bec..eb671160b8 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -50,8 +50,8 @@ android { applicationId "com.instructure.candroid" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 254 - versionName = '6.26.0' + versionCode = 255 + versionName = '6.26.1' vectorDrawables.useSupportLibrary = true multiDexEnabled = true From 4079823f555e1ecfb4242907c50ae81f567548ae Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Mon, 4 Sep 2023 14:58:26 +0200 Subject: [PATCH 59/61] Added different Flutter SDK url for Parent app. (#2138) --- apps/flutter_parent/flutter_parent_sdk_url | 1 + 1 file changed, 1 insertion(+) create mode 100644 apps/flutter_parent/flutter_parent_sdk_url diff --git a/apps/flutter_parent/flutter_parent_sdk_url b/apps/flutter_parent/flutter_parent_sdk_url new file mode 100644 index 0000000000..6fcfede9b9 --- /dev/null +++ b/apps/flutter_parent/flutter_parent_sdk_url @@ -0,0 +1 @@ +https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_2.5.3-stable.tar.xz \ No newline at end of file From 67c28a2c42f006fcce6020e9412455c4fbc980fb Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Tue, 5 Sep 2023 13:54:03 +0200 Subject: [PATCH 60/61] [MBL-16865][Student][Teacher] - Separate InboxE2ETest cases in both apps (Student and Teacher), and major refactor on them. (#2140) refs: MBL-16865 affects: Student, Teacher release note: none --- .../student/ui/e2e/InboxE2ETest.kt | 309 ++++++++++-------- .../instructure/student/ui/pages/InboxPage.kt | 3 +- .../teacher/ui/e2e/InboxE2ETest.kt | 148 +++++++-- 3 files changed, 297 insertions(+), 163 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt index 6e8c9a8398..1eabb2a7a5 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt @@ -44,8 +44,7 @@ class InboxE2ETest: StudentTest() { @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) - fun testInboxE2E() { - + fun testInboxSelectedButtonActionsE2E() { Log.d(PREPARATION_TAG, "Seeding data.") val data = seedData(students = 2, teachers = 1, courses = 1) val teacher = data.teachersList[0] @@ -76,63 +75,6 @@ class InboxE2ETest: StudentTest() { inboxPage.assertHasConversation() inboxPage.assertConversationDisplayed(seededConversation) - Log.d(STEP_TAG,"Click on 'New Message' button.") - inboxPage.pressNewMessageButton() - - val newMessageSubject = "Hey There" - val newMessage = "Just checking in" - Log.d(STEP_TAG,"Create a new message with subject: $newMessageSubject, and message: $newMessage") - newMessagePage.populateMessage(course, student2, newMessageSubject, newMessage) - - Log.d(STEP_TAG,"Click on 'Send' button.") - newMessagePage.clickSend() - - Log.d(STEP_TAG,"Click on 'New Message' button.") - inboxPage.pressNewMessageButton() - - val newGroupMessageSubject = "Group Message" - val newGroupMessage = "Testing Group ${group.name}" - Log.d(STEP_TAG,"Create a new message with subject: $newGroupMessageSubject, and message: $newGroupMessage") - newMessagePage.populateGroupMessage(group, student2, newGroupMessageSubject, newGroupMessage) - - Log.d(STEP_TAG,"Click on 'Send' button.") - newMessagePage.clickSend() - - sleep(2000) // Allow time for messages to propagate - - Log.d(STEP_TAG,"Navigate back to Dashboard Page.") - inboxPage.goToDashboard() - dashboardPage.waitForRender() - - Log.d(STEP_TAG,"Log out with ${student1.name} student.") - leftSideNavigationDrawerPage.logout() - - Log.d(STEP_TAG,"Login with user: ${student2.name}, login id: ${student2.loginId}.") - tokenLogin(student2) - dashboardPage.waitForRender() - - Log.d(STEP_TAG,"Open Inbox Page. Assert that both, the previously seeded 'normal' conversation and the group conversation are displayed.") - dashboardPage.clickInboxTab() - inboxPage.assertConversationDisplayed(seededConversation) - inboxPage.assertConversationDisplayed(newMessageSubject) - inboxPage.assertConversationDisplayed("Group Message") - - Log.d(STEP_TAG,"Select $newGroupMessageSubject conversation.") - inboxPage.openConversation(newMessageSubject) - val newReplyMessage = "This is a quite new reply message." - Log.d(STEP_TAG,"Reply to $newGroupMessageSubject conversation with '$newReplyMessage' message. Assert that the reply is displayed.") - inboxConversationPage.replyToMessage(newReplyMessage) - - Log.d(STEP_TAG,"Delete $newReplyMessage reply and assert is has been deleted.") - inboxConversationPage.deleteMessage(newReplyMessage) - inboxConversationPage.assertMessageNotDisplayed(newReplyMessage) - - Log.d(STEP_TAG,"Delete the whole '$newGroupMessageSubject' subject and assert that it has been removed from the conversation list on the Inbox Page.") - inboxConversationPage.deleteConversation() //After deletion we will be navigated back to Inbox Page - inboxPage.assertConversationNotDisplayed(newMessageSubject) - inboxPage.assertConversationDisplayed(seededConversation) - inboxPage.assertConversationDisplayed("Group Message") - Log.d(STEP_TAG,"Select ${seededConversation.subject} conversation. Assert that is has not been starred already.") inboxPage.openConversation(seededConversation) inboxConversationPage.assertNotStarred() @@ -163,9 +105,8 @@ class InboxE2ETest: StudentTest() { Log.d(STEP_TAG,"Select 'Archived' conversation filter.") inboxPage.filterInbox("Archived") - Log.d(STEP_TAG,"Assert that ${seededConversation.subject} conversation is displayed by the 'Archived' filter, and other conversations are not displayed.") + Log.d(STEP_TAG,"Assert that ${seededConversation.subject} conversation is displayed by the 'Archived' filter.") inboxPage.assertConversationDisplayed(seededConversation) - inboxPage.assertConversationNotDisplayed("Group Message") Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation. Assert that the selected number of conversations on the toolbar is 1." + "Unarchive it, and assert that it is not displayed in the 'ARCHIVED' scope any more.") @@ -181,79 +122,202 @@ class InboxE2ETest: StudentTest() { inboxPage.filterInbox("Inbox") inboxPage.assertConversationDisplayed(seededConversation.subject) - Log.d(STEP_TAG, "Select both of the conversations (${seededConversation.subject} and $newGroupMessageSubject) and star them." + - "Assert that the selected number of conversations on the toolbar is 2 and both of the has been starred.") - inboxPage.selectConversations(listOf(seededConversation.subject, newGroupMessageSubject)) - inboxPage.clickStar() - inboxPage.assertConversationStarred(seededConversation.subject) - inboxPage.assertConversationStarred(newGroupMessageSubject) - - Log.d(STEP_TAG, "Mark them as read (since if at least there is one unread selected, we are showing the 'Mark as Read' icon). Assert that both of them are read.") - inboxPage.clickMarkAsRead() - inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.GONE) - inboxPage.assertUnreadMarkerVisibility(newGroupMessageSubject, ViewMatchers.Visibility.GONE) - - Log.d(STEP_TAG, "Mark them as unread. Assert that both of them will became unread.") - inboxPage.clickMarkAsUnread() - inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.VISIBLE) - inboxPage.assertUnreadMarkerVisibility(newGroupMessageSubject, ViewMatchers.Visibility.VISIBLE) + Log.d(STEP_TAG, "Select the conversations (${seededConversation.subject} and star it." + + "Assert that the selected number of conversations on the toolbar is 1 and the conversation is starred.") + inboxPage.selectConversations(listOf(seededConversation.subject)) + inboxPage.assertSelectedConversationNumber("1") + inboxPage.clickUnstar() + inboxPage.assertConversationNotStarred(seededConversation.subject) - Log.d(STEP_TAG, "Archive both of them. Assert that non of them are displayed in the 'INBOX' scope.") + Log.d(STEP_TAG, "Select the conversations (${seededConversation.subject} and archive it. Assert that it has not displayed in the 'INBOX' scope.") + inboxPage.selectConversations(listOf(seededConversation.subject)) inboxPage.clickArchive() inboxPage.assertConversationNotDisplayed(seededConversation.subject) - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) sleep(2000) - Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope and assert that both of the conversations are displayed there.") + Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope and assert that the conversation is displayed there.") inboxPage.filterInbox("Archived") inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationDisplayed(newGroupMessageSubject) - Log.d(STEP_TAG, "Navigate to 'UNREAD' scope and assert that none of the conversations are displayed there, because a conversation cannot be archived and unread at the same time.") + Log.d(STEP_TAG, "Navigate to 'UNREAD' scope and assert that the conversation is displayed there, because a conversation cannot be archived and unread at the same time.") inboxPage.filterInbox("Unread") inboxPage.assertConversationNotDisplayed(seededConversation.subject) - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) - Log.d(STEP_TAG, "Navigate to 'STARRED' scope and assert that both of the conversations are displayed there.") + Log.d(STEP_TAG, "Navigate to 'STARRED' scope and assert that the conversation is NOT displayed there.") inboxPage.filterInbox("Starred") - inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationDisplayed(newGroupMessageSubject) - - Log.d(STEP_TAG, "Select both of the conversations. Unstar them, and assert that none of them are displayed in the 'STARRED' scope.") - inboxPage.selectConversations(listOf(seededConversation.subject, newGroupMessageSubject)) - inboxPage.clickUnstar() inboxPage.assertConversationNotDisplayed(seededConversation.subject) - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) - sleep(2000) + Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that ${seededConversation.subject} conversation is NOT displayed because it is archived yet.") + inboxPage.filterInbox("Inbox") + inboxPage.assertConversationNotDisplayed(seededConversation.subject) - Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope and assert that both of the conversations are displayed there.") + Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope and Select the conversation. Star it, and assert that it has displayed in the 'STARRED' scope.") inboxPage.filterInbox("Archived") - inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationDisplayed(newGroupMessageSubject) + inboxPage.selectConversations(listOf(seededConversation.subject)) + inboxPage.clickStar() + inboxPage.assertConversationStarred(seededConversation.subject) - Log.d(STEP_TAG, "Select both of the conversations. Unarchive them, and assert that none of them are displayed in the 'ARCHIVED' scope.") - inboxPage.selectConversations(listOf(seededConversation.subject, newGroupMessageSubject)) + Log.d(STEP_TAG, "Select the conversation. Unarchive it, and assert that it has not displayed in the 'ARCHIVED' scope.") + inboxPage.selectConversations(listOf(seededConversation.subject)) inboxPage.clickUnArchive() inboxPage.assertConversationNotDisplayed(seededConversation.subject) - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) + + Log.d(STEP_TAG, "Navigate to 'STARRED' scope and assert that the conversations is displayed there.") + inboxPage.filterInbox("Starred") + inboxPage.assertConversationDisplayed(seededConversation.subject) sleep(2000) - Log.d(STEP_TAG, "Navigate to 'INBOX' scope and assert that both of the conversations are displayed there.") + Log.d(STEP_TAG, "Navigate to 'INBOX' scope and assert that the conversations is displayed there because it is not archived yet.") inboxPage.filterInbox("Inbox") inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationDisplayed(newGroupMessageSubject) + } + + @E2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) + fun testInboxMessageComposeReplyAndOptionMenuActionsE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 2, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val student1 = data.studentsList[0] + val student2 = data.studentsList[1] + + val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) + val group = GroupsApi.createGroup(groupCategory.id, teacher.token) + Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} and ${student2.name} students to the group: ${group.name}.") + GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) + GroupsApi.createGroupMembership(group.id, student2.id, teacher.token) + + Log.d(STEP_TAG,"Login with user: ${student1.name}, login id: ${student1.loginId}.") + tokenLogin(student1) + dashboardPage.waitForRender() + dashboardPage.assertDisplaysCourse(course) + + Log.d(STEP_TAG,"Open Inbox Page. Assert that the previously seeded conversation is displayed.") + dashboardPage.clickInboxTab() + inboxPage.assertInboxEmpty() + + Log.d(PREPARATION_TAG,"Seed an email from the teacher to ${student1.name} and ${student2.name} students.") + val seededConversation = createConversation(teacher, student1, student2)[0] + + Log.d(STEP_TAG,"Refresh the page. Assert that there is a conversation and it is the previously seeded one.") + refresh() + inboxPage.assertHasConversation() + inboxPage.assertConversationDisplayed(seededConversation) + + Log.d(STEP_TAG,"Click on 'New Message' button.") + inboxPage.pressNewMessageButton() - Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation and swipe it right to make it unread. Assert that the conversation became unread.") + val newMessageSubject = "Hey There" + val newMessage = "Just checking in" + Log.d(STEP_TAG,"Create a new message with subject: $newMessageSubject, and message: $newMessage") + newMessagePage.populateMessage(course, student2, newMessageSubject, newMessage) + + Log.d(STEP_TAG,"Click on 'Send' button.") + newMessagePage.clickSend() + + Log.d(STEP_TAG,"Click on 'New Message' button.") + inboxPage.pressNewMessageButton() + + val newGroupMessageSubject = "Group Message" + val newGroupMessage = "Testing Group ${group.name}" + Log.d(STEP_TAG,"Create a new message with subject: $newGroupMessageSubject, and message: $newGroupMessage") + newMessagePage.populateGroupMessage(group, student2, newGroupMessageSubject, newGroupMessage) + + Log.d(STEP_TAG,"Click on 'Send' button.") + newMessagePage.clickSend() + + sleep(2000) // Allow time for messages to propagate + + Log.d(STEP_TAG,"Navigate back to Dashboard Page.") + inboxPage.goToDashboard() + dashboardPage.waitForRender() + + Log.d(STEP_TAG,"Log out with ${student1.name} student.") + leftSideNavigationDrawerPage.logout() + + Log.d(STEP_TAG,"Login with user: ${student2.name}, login id: ${student2.loginId}.") + tokenLogin(student2) + dashboardPage.waitForRender() + + Log.d(STEP_TAG,"Open Inbox Page. Assert that both, the previously seeded 'normal' conversation and the group conversation are displayed.") + dashboardPage.clickInboxTab() + inboxPage.assertConversationDisplayed(seededConversation) + inboxPage.assertConversationDisplayed(newMessageSubject) + inboxPage.assertConversationDisplayed("Group Message") + + Log.d(STEP_TAG,"Select $newGroupMessageSubject conversation.") + inboxPage.openConversation(newMessageSubject) + val newReplyMessage = "This is a quite new reply message." + Log.d(STEP_TAG,"Reply to $newGroupMessageSubject conversation with '$newReplyMessage' message. Assert that the reply is displayed.") + inboxConversationPage.replyToMessage(newReplyMessage) + + Log.d(STEP_TAG,"Delete $newReplyMessage reply and assert is has been deleted.") + inboxConversationPage.deleteMessage(newReplyMessage) + inboxConversationPage.assertMessageNotDisplayed(newReplyMessage) + + Log.d(STEP_TAG,"Delete the whole '$newGroupMessageSubject' subject and assert that it has been removed from the conversation list on the Inbox Page.") + inboxConversationPage.deleteConversation() //After deletion we will be navigated back to Inbox Page + inboxPage.assertConversationNotDisplayed(newMessageSubject) + inboxPage.assertConversationDisplayed(seededConversation) + inboxPage.assertConversationDisplayed("Group Message") + + Log.d(STEP_TAG, "Navigate to 'INBOX' scope and seledct '$newGroupMessageSubject' conversation.") + inboxPage.filterInbox("Inbox") + inboxPage.selectConversation(newGroupMessageSubject) + + Log.d(STEP_TAG, "Delete the '$newGroupMessageSubject' conversation and assert that it has been removed from the 'INBOX' scope.") + inboxPage.clickDelete() + inboxPage.confirmDelete() + inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) + } + + @E2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) + fun testInboxSwipeGesturesE2E() { + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 2, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val student1 = data.studentsList[0] + val student2 = data.studentsList[1] + + val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) + val group = GroupsApi.createGroup(groupCategory.id, teacher.token) + Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} and ${student2.name} students to the group: ${group.name}.") + GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) + GroupsApi.createGroupMembership(group.id, student2.id, teacher.token) + + Log.d(STEP_TAG,"Login with user: ${student1.name}, login id: ${student1.loginId}.") + tokenLogin(student1) + dashboardPage.waitForRender() + dashboardPage.assertDisplaysCourse(course) + + Log.d(STEP_TAG,"Open Inbox Page. Assert that the previously seeded conversation is displayed.") + dashboardPage.clickInboxTab() + inboxPage.assertInboxEmpty() + + Log.d(PREPARATION_TAG,"Seed an email from the teacher to ${student1.name} and ${student2.name} students.") + val seededConversation = createConversation(teacher, student1, student2)[0] + + Log.d(STEP_TAG,"Refresh the page. Assert that there is a conversation and it is the previously seeded one.") + refresh() + inboxPage.assertHasConversation() + inboxPage.assertConversationDisplayed(seededConversation) + + Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation and swipe it right to make it read. Assert that the conversation became read.") inboxPage.selectConversation(seededConversation.subject) inboxPage.swipeConversationRight(seededConversation) - inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.VISIBLE) + inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.GONE) - Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation and swipe it right again to make it read. Assert that the conversation became read.") + Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation and swipe it right again to make it unread. Assert that the conversation became unread.") inboxPage.swipeConversationRight(seededConversation) - inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.GONE) + inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.VISIBLE) Log.d(STEP_TAG, "Swipe '${seededConversation.subject}' left and assert it is removed from the 'INBOX' scope because it has became archived.") inboxPage.swipeConversationLeft(seededConversation) @@ -271,51 +335,30 @@ class InboxE2ETest: StudentTest() { inboxPage.filterInbox("Inbox") inboxPage.assertConversationDisplayed(seededConversation.subject) - Log.d(STEP_TAG, "Select both of the conversations. Star them and mark the unread.") - inboxPage.selectConversations(listOf(seededConversation.subject, newGroupMessageSubject)) + Log.d(STEP_TAG, "Select the conversations. Star it and mark it unread. (Preparing for swipe gestures in 'STARRED' and 'UNREAD' scope.") + inboxPage.selectConversations(listOf(seededConversation.subject)) + inboxPage.assertSelectedConversationNumber("1") inboxPage.clickStar() - inboxPage.assertSelectedConversationNumber("2") + inboxPage.assertConversationStarred(seededConversation.subject) inboxPage.clickMarkAsUnread() sleep(1000) - Log.d(STEP_TAG, "Navigate to 'STARRED' scope. Assert that both of the conversation are displayed in the 'STARRED' scope.") + Log.d(STEP_TAG, "Navigate to 'STARRED' scope. Assert that the conversation is displayed in the 'STARRED' scope.") inboxPage.filterInbox("Starred") inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationDisplayed(newGroupMessageSubject) Log.d(STEP_TAG, "Swipe '${seededConversation.subject}' left and assert it is removed from the 'STARRED' scope because it has became unstarred.") inboxPage.swipeConversationLeft(seededConversation) inboxPage.assertConversationNotDisplayed(seededConversation.subject) - Log.d(STEP_TAG, "Assert that '$newGroupMessageSubject' conversation is unread.") - inboxPage.assertUnreadMarkerVisibility(newGroupMessageSubject, ViewMatchers.Visibility.VISIBLE) - - Log.d(STEP_TAG, "Swipe '$newGroupMessageSubject' conversation right and assert that it has became read.") - inboxPage.swipeConversationRight(newGroupMessageSubject) - inboxPage.assertUnreadMarkerVisibility(newGroupMessageSubject, ViewMatchers.Visibility.GONE) - - Log.d(STEP_TAG, "Navigate to 'UNREAD' scope. Assert that only the '${seededConversation.subject}' conversation is displayed in the 'UNREAD' scope.") + Log.d(STEP_TAG, "Navigate to 'UNREAD' scope. Assert that the conversation is displayed in the 'Unread' scope.") inboxPage.filterInbox("Unread") inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) - Log.d(STEP_TAG, "Swipe '${seededConversation.subject}' conversation left and assert it has been removed from the 'UNREAD' scope since it has became read.") - inboxPage.swipeConversationLeft(seededConversation) + Log.d(STEP_TAG, "Swipe '${seededConversation.subject}' conversation right and assert that it has disappeared from the 'UNREAD' scope.") + inboxPage.swipeConversationRight(seededConversation.subject) inboxPage.assertConversationNotDisplayed(seededConversation.subject) - - Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope. Assert that the '${seededConversation.subject}' conversation is displayed in the 'ARCHIVED' scope.") - inboxPage.filterInbox("Archived") - inboxPage.assertConversationDisplayed(seededConversation.subject) - - Log.d(STEP_TAG, "Navigate to 'INBOX' scope and seledct '$newGroupMessageSubject' conversation.") - inboxPage.filterInbox("Inbox") - inboxPage.selectConversation(newGroupMessageSubject) - - Log.d(STEP_TAG, "Delete the '$newGroupMessageSubject' conversation and assert that it has been removed from the 'INBOX' scope.") - inboxPage.clickDelete() - inboxPage.confirmDelete() - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) } private fun createConversation( diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt index 223ceaa4de..29dd3fc338 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt @@ -141,12 +141,11 @@ class InboxPage : BasePage(R.id.inboxPage) { fun assertConversationNotStarred(subject: String) { val matcher = allOf( withId(R.id.star), - withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), hasSibling(withId(R.id.userName)), hasSibling(withId(R.id.date)), hasSibling(allOf(withId(R.id.subjectView), withText(subject)))) waitForMatcherWithRefreshes(matcher) // May need to refresh before the star shows up - onView(matcher).check(doesNotExist()) + onView(matcher).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt index 72bd37f362..f9ecf29cbc 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt @@ -24,11 +24,11 @@ class InboxE2ETest : TeacherTest() { override fun enableAndConfigureAccessibilityChecks() = Unit + @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) - fun testInboxE2E() { - + fun testInboxMessageComposeReplyAndOptionMenuActionsE2E() { Log.d(PREPARATION_TAG, "Seeding data.") val data = seedData(students = 2, teachers = 1, courses = 1) val teacher = data.teachersList[0] @@ -152,6 +152,43 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG, "Assert that the '${seedConversation[0]}' conversation is disappeared because it's not starred yet.") dashboardPage.assertPageObjects() inboxPage.assertInboxEmpty() + } + + @E2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) + fun testInboxSelectedButtonActionsE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 2, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val student1 = data.studentsList[0] + val student2 = data.studentsList[1] + + val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) + val group = GroupsApi.createGroup(groupCategory.id, teacher.token) + Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} student to the group: ${group.name}.") + GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) + + Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + tokenLogin(teacher) + dashboardPage.waitForRender() + dashboardPage.assertDisplaysCourse(course) + + Log.d(STEP_TAG,"Open Inbox. Assert that Inbox is empty.") + dashboardPage.openInbox() + inboxPage.assertInboxEmpty() + + Log.d(PREPARATION_TAG, "Seed an Inbox conversation via API.") + val seedConversation = ConversationsApi.createConversation( + token = student1.token, + recipients = listOf(teacher.id.toString()) + ) + + Log.d(STEP_TAG, "Refresh the page. Assert that the conversation displayed as unread.") + inboxPage.refresh() + inboxPage.assertThereIsAnUnreadMessage(true) Log.d(PREPARATION_TAG, "Seed another Inbox conversation via API.") val seedConversation2 = ConversationsApi.createConversation( @@ -169,7 +206,8 @@ class InboxE2ETest : TeacherTest() { body = "Third body" ) - Log.d(STEP_TAG,"Filter the Inbox by selecting 'Inbox' category from the spinner on Inbox Page. Assert that the '${seedConversation[0]}' conversation is displayed.") + Log.d(STEP_TAG,"Refresh the page. Filter the Inbox by selecting 'Inbox' category from the spinner on Inbox Page. Assert that the '${seedConversation[0]}' conversation is displayed. Assert that the conversation is unread yet.") + inboxPage.refresh() inboxPage.filterMessageScope("Inbox") inboxPage.assertHasConversation() @@ -185,8 +223,8 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG, "Select '${seedConversation2[0].subject}' conversation and unarchive it." + "Assert that the selected number of conversation on the toolbar is 1 and '${seedConversation2[0].subject}' conversation is not displayed in the 'ARCHIVED' scope.") inboxPage.selectConversation(seedConversation2[0]) - inboxPage.clickUnArchive() inboxPage.assertSelectedConversationNumber("1") + inboxPage.clickUnArchive() inboxPage.assertConversationNotDisplayed(seedConversation2[0].subject) Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that ${seedConversation2[0].subject} conversation is displayed.") @@ -196,8 +234,8 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG, "Select both of the conversations (${seedConversation[0].subject} and ${seedConversation2[0].subject} and star them." + "Assert that both of the has been starred and the selected number of conversations on the toolbar shows 2") inboxPage.selectConversations(listOf(seedConversation2[0].subject, seedConversation3[0].subject)) - inboxPage.clickStar() inboxPage.assertSelectedConversationNumber("2") + inboxPage.clickStar() inboxPage.assertConversationStarred(seedConversation2[0].subject) inboxPage.assertConversationStarred(seedConversation3[0].subject) @@ -252,15 +290,69 @@ class InboxE2ETest : TeacherTest() { inboxPage.filterMessageScope("Inbox") inboxPage.assertConversationDisplayed(seedConversation2[0].subject) inboxPage.assertConversationDisplayed(seedConversation3[0].subject) + } + + @E2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) + fun testInboxSwipeGesturesE2E() { + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 2, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val student1 = data.studentsList[0] + val student2 = data.studentsList[1] + + val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) + val group = GroupsApi.createGroup(groupCategory.id, teacher.token) + Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} student to the group: ${group.name}.") + GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) + + Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + tokenLogin(teacher) + dashboardPage.waitForRender() + dashboardPage.assertDisplaysCourse(course) + + Log.d(STEP_TAG,"Open Inbox. Assert that Inbox is empty.") + dashboardPage.openInbox() + inboxPage.assertInboxEmpty() + + Log.d(PREPARATION_TAG, "Seed an Inbox conversation via API.") + val seedConversation = ConversationsApi.createConversation( + token = student1.token, + recipients = listOf(teacher.id.toString()) + ) + + Log.d(STEP_TAG,"Refresh the page. Assert that the previously seeded Inbox conversation is displayed. Assert that the message is unread yet.") + inboxPage.refresh() + inboxPage.assertHasConversation() + inboxPage.assertThereIsAnUnreadMessage(true) - Log.d(STEP_TAG, "Select '${seedConversation2[0].subject}' conversation and swipe it right to make it unread. Assert that the conversation became unread.") + Log.d(PREPARATION_TAG, "Seed another Inbox conversation via API.") + val seedConversation2 = ConversationsApi.createConversation( + token = student1.token, + recipients = listOf(teacher.id.toString()), + subject = "Second conversation", + body = "Second body" + ) + + Log.d(PREPARATION_TAG, "Seed a third Inbox conversation via API.") + val seedConversation3 = ConversationsApi.createConversation( + token = student2.token, + recipients = listOf(teacher.id.toString()), + subject = "Third conversation", + body = "Third body" + ) + + Log.d(STEP_TAG, "Select '${seedConversation2[0].subject}' conversation and swipe it right to make it read. Assert that the conversation became read.") + inboxPage.refresh() inboxPage.selectConversation(seedConversation2[0].subject) inboxPage.swipeConversationRight(seedConversation2[0]) - inboxPage.assertUnreadMarkerVisibility(seedConversation2[0].subject, ViewMatchers.Visibility.VISIBLE) + inboxPage.assertUnreadMarkerVisibility(seedConversation2[0].subject, ViewMatchers.Visibility.GONE) - Log.d(STEP_TAG, "Select '${seedConversation2[0].subject}' conversation and swipe it right again to make it read. Assert that the conversation became read.") + Log.d(STEP_TAG, "Select '${seedConversation2[0].subject}' conversation and swipe it right again to make it unread. Assert that the conversation became unread.") inboxPage.swipeConversationRight(seedConversation2[0]) - inboxPage.assertUnreadMarkerVisibility(seedConversation2[0].subject, ViewMatchers.Visibility.GONE) + inboxPage.assertUnreadMarkerVisibility(seedConversation2[0].subject, ViewMatchers.Visibility.VISIBLE) Log.d(STEP_TAG, "Swipe '${seedConversation2[0].subject}' left and assert it is removed from the 'INBOX' scope because it has became archived.") inboxPage.swipeConversationLeft(seedConversation2[0]) @@ -281,7 +373,7 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG, "Select both of the conversations. Star them and mark the unread.") inboxPage.selectConversations(listOf(seedConversation2[0].subject, seedConversation3[0].subject)) inboxPage.clickStar() - inboxPage.clickMarkAsUnread() + inboxPage.clickMarkAsRead() Log.d(STEP_TAG, "Navigate to 'STARRED' scope. Assert that both of the conversation are displayed in the 'STARRED' scope.") inboxPage.filterMessageScope("Starred") @@ -292,42 +384,42 @@ class InboxE2ETest : TeacherTest() { inboxPage.swipeConversationLeft(seedConversation2[0]) inboxPage.assertConversationNotDisplayed(seedConversation2[0].subject) - Log.d(STEP_TAG, "Assert that '${seedConversation3[0].subject}' conversation is unread.") - inboxPage.assertUnreadMarkerVisibility(seedConversation3[0].subject, ViewMatchers.Visibility.VISIBLE) + Log.d(STEP_TAG, "Assert that '${seedConversation3[0].subject}' conversation is read.") + inboxPage.assertUnreadMarkerVisibility(seedConversation3[0].subject, ViewMatchers.Visibility.GONE) - Log.d(STEP_TAG, "Swipe '${seedConversation3[0].subject}' conversation right and assert that it has became read.") + Log.d(STEP_TAG, "Swipe '${seedConversation3[0].subject}' conversation right and assert that it has became unread.") inboxPage.swipeConversationRight(seedConversation3[0].subject) - inboxPage.assertUnreadMarkerVisibility(seedConversation3[0].subject, ViewMatchers.Visibility.GONE) + inboxPage.assertUnreadMarkerVisibility(seedConversation3[0].subject, ViewMatchers.Visibility.VISIBLE) - Log.d(STEP_TAG, "Navigate to 'UNREAD' scope. Assert that only the '${seedConversation2[0].subject}' conversation is displayed in the 'UNREAD' scope.") + Log.d(STEP_TAG, "Navigate to 'UNREAD' scope. Assert that only the '${seedConversation3[0].subject}' conversation is displayed in the 'UNREAD' scope.") inboxPage.filterMessageScope("Unread") - inboxPage.assertConversationDisplayed(seedConversation2[0].subject) - inboxPage.assertConversationNotDisplayed(seedConversation3[0].subject) - - Log.d(STEP_TAG, "Swipe '${seedConversation2[0].subject}' conversation left and assert it has been removed from the 'UNREAD' scope since it has became read.") - inboxPage.swipeConversationLeft(seedConversation2[0]) + inboxPage.assertConversationDisplayed(seedConversation3[0].subject) inboxPage.assertConversationNotDisplayed(seedConversation2[0].subject) + Log.d(STEP_TAG, "Swipe '${seedConversation3[0].subject}' conversation left and assert it has been removed from the 'UNREAD' scope since it has became read.") + inboxPage.swipeConversationLeft(seedConversation3[0]) + inboxPage.assertConversationNotDisplayed(seedConversation3[0].subject) + Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope. Assert that the '${seedConversation2[0].subject}' conversation is displayed in the 'ARCHIVED' scope.") inboxPage.filterMessageScope("Archived") - inboxPage.assertConversationDisplayed(seedConversation2[0].subject) + inboxPage.assertConversationDisplayed(seedConversation3[0].subject) Log.d(STEP_TAG, "Navigate to 'INBOX' scope and select '${seedConversation3[0].subject}' conversation.") inboxPage.filterMessageScope("Inbox") - inboxPage.selectConversation(seedConversation3[0].subject) + inboxPage.selectConversation(seedConversation2[0].subject) - Log.d(STEP_TAG, "Delete the '${seedConversation3[0].subject}' conversation and assert that it has been removed from the 'INBOX' scope.") + Log.d(STEP_TAG, "Delete the '${seedConversation2[0].subject}' conversation and assert that it has been removed from the 'INBOX' scope.") inboxPage.clickDelete() inboxPage.confirmDelete() - inboxPage.assertConversationNotDisplayed(seedConversation3[0].subject) + inboxPage.assertConversationNotDisplayed(seedConversation2[0].subject) - Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope. Assert that the '${seedConversation2[0].subject}' conversation is displayed in the 'ARCHIVED' scope.") + Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope. Assert that the '${seedConversation3[0].subject}' conversation is displayed in the 'ARCHIVED' scope.") inboxPage.filterMessageScope("Archived") - Log.d(STEP_TAG,"Click on the '${seedConversation2[0].subject}' conversation.") - inboxPage.clickConversation(seedConversation2[0]) + Log.d(STEP_TAG,"Click on the '${seedConversation3[0].subject}' conversation.") + inboxPage.clickConversation(seedConversation3[0]) - Log.d(STEP_TAG, "Delete the '${seedConversation2[0]}' conversation and assert that it has disappeared from the list.") + Log.d(STEP_TAG, "Delete the '${seedConversation3[0]}' conversation and assert that it has disappeared from the list.") inboxMessagePage.deleteConversation() Log.d(STEP_TAG, "Assert that the empty view is displayed.") From 7be0ff94e8ce3db260266f821316fe3e7b812f13 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Mon, 11 Sep 2023 12:43:51 +0200 Subject: [PATCH 61/61] Updated version. --- apps/teacher/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/teacher/build.gradle b/apps/teacher/build.gradle index 0916c4c363..3ffda0139f 100644 --- a/apps/teacher/build.gradle +++ b/apps/teacher/build.gradle @@ -39,8 +39,8 @@ android { defaultConfig { minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 59 - versionName = '1.25.0' + versionCode = 60 + versionName = '1.26.0' vectorDrawables.useSupportLibrary = true multiDexEnabled true testInstrumentationRunner 'com.instructure.teacher.ui.espresso.TeacherHiltTestRunner'