diff --git a/apps/flutter_parent/android/app/src/main/AndroidManifest.xml b/apps/flutter_parent/android/app/src/main/AndroidManifest.xml index 7968bc38fe..92cc44acbe 100644 --- a/apps/flutter_parent/android/app/src/main/AndroidManifest.xml +++ b/apps/flutter_parent/android/app/src/main/AndroidManifest.xml @@ -37,7 +37,7 @@ - + diff --git a/apps/flutter_parent/lib/screens/courses/courses_screen.dart b/apps/flutter_parent/lib/screens/courses/courses_screen.dart index c3484287c7..36ab053186 100644 --- a/apps/flutter_parent/lib/screens/courses/courses_screen.dart +++ b/apps/flutter_parent/lib/screens/courses/courses_screen.dart @@ -130,11 +130,14 @@ class _CoursesScreenState extends State { // If there is no current grade, return 'No grade' // Otherwise, we have a grade, so check if we have the actual grade string // or a score + var formattedScore = (grade.currentScore() != null && !(course.settings?.restrictQuantitativeData ?? false)) + ? format.format(grade.currentScore()! / 100) + : ''; var text = grade.noCurrentGrade() ? L10n(context).noGrade : grade.currentGrade()?.isNotEmpty == true - ? grade.currentGrade()! - : format.format(grade.currentScore()! / 100); + ? "${grade.currentGrade()}${formattedScore.isNotEmpty ? ' $formattedScore' : ''}" + : formattedScore; return Text( text, 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 97e7c9222e..4163980c2a 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 @@ -255,22 +255,25 @@ class _CourseGradeHeader extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(L10n(context).courseTotalGradeLabel, style: textTheme.bodyMedium), - Text(_courseGrade(context, grade), style: textTheme.bodyMedium, key: Key("total_grade")), + Text(_courseGrade(context, grade, model.courseSettings?.restrictQuantitativeData ?? false), style: textTheme.bodyMedium, key: Key("total_grade")), ], ), ); } - String _courseGrade(BuildContext context, CourseGrade grade) { + String _courseGrade(BuildContext context, CourseGrade grade, bool restrictQuantitativeData) { final format = NumberFormat.percentPattern(); format.maximumFractionDigits = 2; if (grade.noCurrentGrade()) { return L10n(context).noGrade; } else { + var formattedScore = (grade.currentScore() != null && restrictQuantitativeData == false) + ? format.format(grade.currentScore()! / 100) + : ''; return grade.currentGrade()?.isNotEmpty == true - ? grade.currentGrade()! - : format.format(grade.currentScore()! / 100); // format multiplies by 100 for percentages + ? "${grade.currentGrade()}${formattedScore.isNotEmpty ? ' $formattedScore' : ''}" + : formattedScore; } } } @@ -375,7 +378,7 @@ class _AssignmentRow extends StatelessWidget { final submission = assignment.submission(studentId); - final restrictQuantitativeData = course?.settings?.restrictQuantitativeData ?? false; + final restrictQuantitativeData = course.settings?.restrictQuantitativeData ?? false; if (submission?.excused ?? false) { text = restrictQuantitativeData 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 7ef1f02a8a..9ece106217 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 @@ -427,6 +427,30 @@ void main() { expect(find.text(grade), findsOneWidget); }); + testWidgetsWithAccessibilityChecks('from current grade and score', (tester) async { + final grade = 'Big fat F'; + final score = 15.15; + final groups = [ + _mockAssignmentGroup(assignments: [_mockAssignment()]) + ]; + final enrollment = Enrollment((b) => b + ..enrollmentState = 'active' + ..grades = _mockGrade(currentScore: score, currentGrade: grade)); + final model = CourseDetailsModel(_student, _courseId); + model.course = _mockCourse(); + when(interactor.loadAssignmentGroups(_courseId, _studentId, null)).thenAnswer((_) async => groups); + when(interactor.loadGradingPeriods(_courseId)).thenAnswer((_) async => + GradingPeriodResponse((b) => b..gradingPeriods = BuiltList.of([]).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 + + expect(find.text("$grade $score%"), findsOneWidget); + }); + testWidgetsWithAccessibilityChecks('is not shown when locked', (tester) async { final groups = [ _mockAssignmentGroup(assignments: [_mockAssignment()]) @@ -497,6 +521,31 @@ void main() { expect(find.text(grade), findsOneWidget); }); + testWidgetsWithAccessibilityChecks('only grade is shown when restricted', (tester) async { + final grade = 'Big fat F'; + final score = 15.15; + final groups = [ + _mockAssignmentGroup(assignments: [_mockAssignment()]) + ]; + final enrollment = Enrollment((b) => b + ..enrollmentState = 'active' + ..grades = _mockGrade(currentScore: score, 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([]).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()]) @@ -780,7 +829,7 @@ Course _mockCourse() { GradeBuilder _mockGrade({double? currentScore, double? finalScore, String? currentGrade, String? finalGrade}) { return GradeBuilder() ..htmlUrl = '' - ..currentScore = currentScore ?? 0 + ..currentScore = currentScore ..finalScore = finalScore ?? 0 ..currentGrade = currentGrade ?? '' ..finalGrade = finalGrade ?? ''; 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 864d68f7de..6dde78c2a0 100644 --- a/apps/flutter_parent/test/screens/courses/courses_screen_test.dart +++ b/apps/flutter_parent/test/screens/courses/courses_screen_test.dart @@ -41,7 +41,6 @@ import 'package:provider/provider.dart'; import '../../utils/accessibility_utils.dart'; import '../../utils/platform_config.dart'; import '../../utils/test_app.dart'; -import '../../utils/test_helpers/mock_helpers.dart'; import '../../utils/test_helpers/mock_helpers.mocks.dart'; void main() { @@ -174,6 +173,48 @@ void main() { expect(gradeWidget, findsNWidgets(courses.length)); }); + testWidgetsWithAccessibilityChecks('shows grade and score if there is a current grade and score', (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', computedCurrentScore: 75)], + ), + ), + ); + + _setupLocator(_MockedCoursesInteractor(courses: courses)); + + await tester.pumpWidget(_testableMaterialWidget()); + await tester.pumpAndSettle(); + + final gradeWidget = find.text('A 75%'); + expect(gradeWidget, findsNWidgets(courses.length)); + }); + + testWidgetsWithAccessibilityChecks('shows grade only if there is a current grade and score and restricted', (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', computedCurrentScore: 75)], + ), + ).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)); + }); + testWidgetsWithAccessibilityChecks('shows score if there is a grade but no grade string', (tester) async { var student = _mockStudent('1'); var courses = List.generate( diff --git a/apps/settings.gradle b/apps/settings.gradle index 9fff40bf18..8486e179b4 100644 --- a/apps/settings.gradle +++ b/apps/settings.gradle @@ -1,7 +1,20 @@ +pluginManagement { + buildscript { + repositories { + mavenCentral() + maven { + url = uri("https://storage.googleapis.com/r8-releases/raw") + } + } + dependencies { + classpath("com.android.tools:r8:8.2.47") + } + } +} + /* Top-level project modules */ include ':student' include ':teacher' - /* Flutter embed modules */ setBinding(new Binding([gradle: this])) @@ -36,4 +49,4 @@ project(':pandautils').projectDir = new File(rootProject.projectDir, '/../libs/p project(':rceditor').projectDir = new File(rootProject.projectDir, '/../libs/rceditor') project(':recyclerview').projectDir = new File(rootProject.projectDir, '/../libs/recyclerview') project(':pandares').projectDir = new File(rootProject.projectDir, '/../libs/pandares') -project(':DocumentScanner').projectDir = new File(rootProject.projectDir, '/../libs/DocumentScanner') +project(':DocumentScanner').projectDir = new File(rootProject.projectDir, '/../libs/DocumentScanner') \ No newline at end of file diff --git a/apps/student/build.gradle b/apps/student/build.gradle index 5552a9bf58..815299e864 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 = 259 - versionName = '7.1.0' + versionCode = 260 + versionName = '7.2.0' vectorDrawables.useSupportLibrary = true multiDexEnabled = true @@ -128,6 +128,14 @@ android { } } + debugMinify { + initWith debug + debuggable false + minifyEnabled true + shrinkResources true + matchingFallbacks = ['debug'] + } + release { signingConfig signingConfigs.release debuggable false @@ -240,6 +248,7 @@ android { hilt { enableTransformForLocalTests = true enableAggregatingTask = false + enableExperimentalClasspathAggregation = true } } @@ -350,6 +359,8 @@ dependencies { implementation Libs.ROOM_COROUTINES testImplementation Libs.HAMCREST + + androidTestImplementation Libs.COMPOSE_UI_TEST } // Comment out this line if the reporting logic starts going wonky. diff --git a/apps/student/flank.yml b/apps/student/flank.yml index e6a449008d..45f1d8bccf 100644 --- a/apps/student/flank.yml +++ b/apps/student/flank.yml @@ -1,8 +1,8 @@ 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: ./build/intermediates/apk/qa/debug/student-qa-debug.apk +# test: ./build/intermediates/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 diff --git a/apps/student/flank_coverage.yml b/apps/student/flank_coverage.yml index 67858de14e..3af12c3e0b 100644 --- a/apps/student/flank_coverage.yml +++ b/apps/student/flank_coverage.yml @@ -19,10 +19,10 @@ gcloud: directories-to-pull: - /sdcard/ test-targets: - - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub + - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.OfflineE2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubCoverage device: - - model: NexusLowRes - version: 26 + - model: Pixel2.arm + version: 29 locale: en_US orientation: portrait diff --git a/apps/student/flank_e2e_coverage.yml b/apps/student/flank_e2e_coverage.yml index ef7cccbfd2..55a9ea0a1b 100644 --- a/apps/student/flank_e2e_coverage.yml +++ b/apps/student/flank_e2e_coverage.yml @@ -19,11 +19,11 @@ gcloud: directories-to-pull: - /sdcard/ test-targets: - - annotation com.instructure.canvas.espresso.E2E - - notAnnotation com.instructure.canvas.espresso.Stub + - annotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.OfflineE2E + - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubCoverage device: - - model: Nexus6P - version: 26 + - model: Pixel2.arm + version: 29 locale: en_US orientation: portrait diff --git a/apps/student/flank_e2e_offline.yml b/apps/student/flank_e2e_offline.yml index 4d76f14bd3..0f091632dd 100644 --- a/apps/student/flank_e2e_offline.yml +++ b/apps/student/flank_e2e_offline.yml @@ -21,6 +21,6 @@ gcloud: orientation: portrait flank: - testShards: 1 + testShards: 10 testRuns: 1 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 e78d1f96cb..c61b83cc1e 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 @@ -24,24 +24,22 @@ import androidx.test.rule.GrantPermissionRule import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.canvas.espresso.checkToastText import com.instructure.dataseeding.api.AssignmentGroupsApi import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.CoursesApi import com.instructure.dataseeding.api.SubmissionsApi -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.GradingType -import com.instructure.dataseeding.model.SubmissionApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 +import com.instructure.student.R import com.instructure.student.ui.pages.AssignmentListPage import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.ViewUtils @@ -54,6 +52,7 @@ import org.junit.Test @HiltAndroidTest class AssignmentsE2ETest: StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -65,6 +64,117 @@ class AssignmentsE2ETest: StudentTest() { android.Manifest.permission.CAMERA ) + @E2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.E2E, SecondaryFeatureCategory.ASSIGNMENT_REMINDER) + fun testAssignmentReminderE2E() { + + 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] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${course.name}' course with 2 days ahead due date.") + val testAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, dueAt = 2.days.fromNow.iso8601, pointsPossible = 15.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) + + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${course.name}' course with 2 days past due date.") + val alreadyPastAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, dueAt = 2.days.ago.iso8601, pointsPossible = 15.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) + + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG,"Select course: '${course.name}'.") + dashboardPage.selectCourse(course) + + Log.d(STEP_TAG,"Navigate to course Assignments Page.") + courseBrowserPage.selectAssignments() + + Log.d(STEP_TAG,"Click on assignment '${testAssignment.name}'.") + assignmentListPage.clickAssignment(testAssignment) + + Log.d(STEP_TAG, "Assert that the corresponding views are displayed on the Assignment Details Page. Assert that the reminder section is displayed as well.") + assignmentDetailsPage.assertPageObjects() + assignmentDetailsPage.assertReminderSectionDisplayed() + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select '1 Hour Before' and assert that the reminder has been picked up and displayed on the Assignment Details Page.") + assignmentDetailsPage.selectTimeOption("1 Hour Before") + assignmentDetailsPage.assertReminderDisplayedWithText("1 Hour Before") + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select '1 Hour Before' again, and assert that a toast message is occurring which warns that we cannot pick up the same time reminder twice.") + assignmentDetailsPage.selectTimeOption("1 Hour Before") + checkToastText(R.string.reminderAlreadySet, activityRule.activity) + + Log.d(STEP_TAG, "Remove the '1 Hour Before' reminder, confirm the deletion dialog and assert that the '1 Hour Before' reminder is not displayed any more.") + assignmentDetailsPage.removeReminderWithText("1 Hour Before") + assignmentDetailsPage.assertReminderNotDisplayedWithText("1 Hour Before") + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select 'Custom' reminder.") + assignmentDetailsPage.clickCustom() + + Log.d(STEP_TAG, "Assert that the 'Done' button is disabled by default.") + assignmentDetailsPage.assertDoneButtonIsDisabled() + + Log.d(STEP_TAG, "Fill the quantity text input with '15' and assert that the 'Done' button is still disabled since there is no option selected yet.") + assignmentDetailsPage.fillQuantity("15") + assignmentDetailsPage.assertDoneButtonIsDisabled() + + Log.d(STEP_TAG, "Select the 'Hours Before' option, and click on 'Done' button, since it will be enabled because both the quantity and option are filled and selected.") + assignmentDetailsPage.clickHoursBefore() + assignmentDetailsPage.clickDone() + + Log.d(STEP_TAG, "Assert that the '15 Hours Before' reminder is displayed on the Assignment Details Page.") + assignmentDetailsPage.assertReminderDisplayedWithText("15 Hours Before") + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select '1 Week Before' and assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for an assignment which ends tomorrow).") + assignmentDetailsPage.selectTimeOption("1 Week Before") + assignmentDetailsPage.assertReminderNotDisplayedWithText("1 Week Before") + checkToastText(R.string.reminderInPast, activityRule.activity) + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select '1 Day Before' and assert that the reminder has been picked up and displayed on the Assignment Details Page.") + assignmentDetailsPage.selectTimeOption("1 Day Before") + assignmentDetailsPage.assertReminderDisplayedWithText("1 Day Before") + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select 'Custom' reminder.") + assignmentDetailsPage.clickCustom() + + Log.d(STEP_TAG, "Fill the quantity text input with '24' and select 'Hours Before' as option. Click on 'Done'.") + assignmentDetailsPage.fillQuantity("24") + assignmentDetailsPage.clickHoursBefore() + assignmentDetailsPage.clickDone() + + Log.d(STEP_TAG, "Assert that a toast message is occurring which warns that we cannot pick up the same time reminder twice. (Because 1 days and 24 hours is the same)") + checkToastText(R.string.reminderAlreadySet, activityRule.activity) + + Log.d(STEP_TAG, "Navigate back to Assignment List Page.") + Espresso.pressBack() + + Log.d(STEP_TAG,"Click on assignment '${alreadyPastAssignment.name}'.") + assignmentListPage.clickAssignment(alreadyPastAssignment) + + Log.d(STEP_TAG, "Assert that the reminder section is NOT displayed, because the '${alreadyPastAssignment.name}' assignment has already passed..") + assignmentDetailsPage.assertReminderSectionNotDisplayed() + } + @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) @@ -77,7 +187,14 @@ class AssignmentsE2ETest: StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + val pointsTextAssignment = AssignmentsApi.createAssignment( + courseId = course.id, + teacherToken = teacher.token, + gradingType = GradingType.POINTS, + pointsPossible = 15.0, + dueAt = 1.days.fromNow.iso8601, + submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY) + ) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -106,14 +223,14 @@ class AssignmentsE2ETest: StudentTest() { Espresso.pressBack() Log.d(PREPARATION_TAG,"Submit assignment: ${pointsTextAssignment.name} for student: ${student.name}.") - submitAssignment(pointsTextAssignment, course, student) + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) assignmentDetailsPage.refresh() assignmentDetailsPage.assertStatusSubmitted() assignmentDetailsPage.assertSubmissionAndRubricLabel() Log.d(PREPARATION_TAG,"Grade submission: ${pointsTextAssignment.name} with 13 points.") - val textGrade = gradeSubmission(teacher, course, pointsTextAssignment.id, student, "13") + val textGrade = SubmissionsApi.gradeSubmission(teacher.token, course.id, pointsTextAssignment.id, student.id, postedGrade = "13") Log.d(STEP_TAG,"Refresh the page. Assert that the assignment ${pointsTextAssignment.name} has been graded with 13 points.") assignmentDetailsPage.refresh() @@ -131,19 +248,19 @@ class AssignmentsE2ETest: StudentTest() { fun testLetterGradeTextAssignmentE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val letterGradeTextAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0) + val letterGradeTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Submit assignment: ${letterGradeTextAssignment.name} for student: ${student.name}.") - submitAssignment(letterGradeTextAssignment, course, student) + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, letterGradeTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) Log.d(PREPARATION_TAG,"Grade submission: ${letterGradeTextAssignment.name} with 13 points.") - val submissionGrade = gradeSubmission(teacher, course, letterGradeTextAssignment.id, student, "13") + val submissionGrade = SubmissionsApi.gradeSubmission(teacher.token, course.id, letterGradeTextAssignment.id, student.id, postedGrade = "13") Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -163,13 +280,13 @@ class AssignmentsE2ETest: StudentTest() { fun testPercentageFileAssignmentWithCommentE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val percentageFileAssignment = createAssignment(course.id, teacher, GradingType.PERCENT, 25.0, allowedExtensions = listOf("txt", "pdf", "jpg"), submissionType = listOf(SubmissionType.ONLINE_UPLOAD)) + val percentageFileAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.PERCENT, pointsPossible = 25.0, allowedExtensions = listOf("txt", "pdf", "jpg"), submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD)) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -186,17 +303,22 @@ class AssignmentsE2ETest: StudentTest() { assignmentListPage.clickAssignment(percentageFileAssignment) Log.d(PREPARATION_TAG, "Seed a text file.") - val uploadInfo = uploadTextFile(courseId = course.id, assignmentId = percentageFileAssignment.id, token = student.token, fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION) + val uploadInfo = uploadTextFile( + courseId = course.id, + assignmentId = percentageFileAssignment.id, + token = student.token, + fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION + ) Log.d(PREPARATION_TAG,"Submit ${percentageFileAssignment.name} assignment for ${student.name} student.") - submitCourseAssignment(course, percentageFileAssignment, uploadInfo, student) + SubmissionsApi.submitCourseAssignment(course.id, student.token, percentageFileAssignment.id, SubmissionType.ONLINE_UPLOAD, fileIds = mutableListOf(uploadInfo.id)) Log.d(STEP_TAG,"Refresh the page. Assert that the ${percentageFileAssignment.name} assignment has been submitted.") assignmentDetailsPage.refresh() assignmentDetailsPage.assertAssignmentSubmitted() Log.d(PREPARATION_TAG,"Grade ${percentageFileAssignment.name} assignment with 22 percentage.") - gradeSubmission(teacher, course, percentageFileAssignment, student,"22") + SubmissionsApi.gradeSubmission(teacher.token, course.id, percentageFileAssignment.id, student.id, postedGrade = "22") Log.d(STEP_TAG,"Refresh the page. Assert that the ${percentageFileAssignment.name} assignment has been graded with 22 percentage.") assignmentDetailsPage.refresh() @@ -224,62 +346,47 @@ class AssignmentsE2ETest: StudentTest() { submissionDetailsPage.assertFileDisplayed(uploadInfo.fileName) } - private fun submitCourseAssignment( - course: CourseApiModel, - percentageFileAssignment: AssignmentApiModel, - uploadInfo: AttachmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_UPLOAD, - courseId = course.id, - assignmentId = percentageFileAssignment.id, - fileIds = listOf(uploadInfo.id).toMutableList(), - studentToken = student.token - ) - } - @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) fun testMultipleAssignmentsE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val letterGradeTextAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val letterGradeTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Submit ${letterGradeTextAssignment.name} assignment for ${student.name} student.") - submitAssignment(letterGradeTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Submit '${letterGradeTextAssignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, letterGradeTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) - Log.d(PREPARATION_TAG,"Grade ${letterGradeTextAssignment.name} assignment with 16.") - gradeSubmission(teacher, course, letterGradeTextAssignment, student, "16") + Log.d(PREPARATION_TAG,"Grade '${letterGradeTextAssignment.name}' assignment with 16.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, letterGradeTextAssignment.id, student.id, postedGrade = "16") - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val pointsTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Submit ${pointsTextAssignment.name} assignment for ${student.name} student.") - submitAssignment(pointsTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Submit '${pointsTextAssignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) - Log.d(PREPARATION_TAG,"Grade ${pointsTextAssignment.name} assignment with 13 points.") - gradeSubmission(teacher, course, pointsTextAssignment.id, student, "13") + Log.d(PREPARATION_TAG,"Grade '${pointsTextAssignment.name}' assignment with 13 points.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, pointsTextAssignment.id, student.id, postedGrade = "13") 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 it's Assignments Page.") + Log.d(STEP_TAG,"Select '${course.name}' course and navigate to it's Assignments Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectAssignments() - Log.d(STEP_TAG,"Assert that ${pointsTextAssignment.name} assignment is displayed with the corresponding grade: 13.") + Log.d(STEP_TAG,"Assert that '${pointsTextAssignment.name}' assignment is displayed with the corresponding grade: 13.") assignmentListPage.assertHasAssignment(pointsTextAssignment,"13") - Log.d(STEP_TAG,"Assert that ${letterGradeTextAssignment.name} assignment is displayed with the corresponding grade: 16.") + Log.d(STEP_TAG,"Assert that '${letterGradeTextAssignment.name}' assignment is displayed with the corresponding grade: 16.") assignmentListPage.assertHasAssignment(letterGradeTextAssignment, "16") } @@ -288,28 +395,28 @@ class AssignmentsE2ETest: StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) fun testFilterAndSortAssignmentsE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") - val upcomingAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0) + val upcomingAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") - val missingAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0, 2.days.ago.iso8601) + val missingAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, dueAt = 2.days.ago.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Seeding a GRADED assignment for ${course.name} course.") - val gradedAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0) + val gradedAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Grade the '${gradedAssignment.name}' with '11' points out of 20.") - gradeSubmission(teacher, course, gradedAssignment, student, "11") + SubmissionsApi.gradeSubmission(teacher.token, course.id, gradedAssignment.id, student.id, postedGrade = "11") Log.d(PREPARATION_TAG,"Create an Assignment Group for '${course.name}' course.") - val assignmentGroup = createAssignmentGroup(teacher, course) + val assignmentGroup = AssignmentGroupsApi.createAssignmentGroup(teacher.token, course.id, name = "Discussions") Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") - val otherTypeAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0, assignmentGroupId = assignmentGroup.id) + val otherTypeAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, assignmentGroupId = assignmentGroup.id, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -402,44 +509,32 @@ class AssignmentsE2ETest: StudentTest() { assignmentListPage.assertAssignmentNotDisplayed(gradedAssignment.name) } - private fun createAssignmentGroup( - teacher: CanvasUserApiModel, - course: CourseApiModel - ) = AssignmentGroupsApi.createAssignmentGroup( - token = teacher.token, - courseId = course.id, - name = "Discussions", - position = null, - groupWeight = null, - sisSourceId = null - ) - @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.COMMENTS, TestCategory.E2E) fun testMediaCommentsE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val assignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Submit ${assignment.name} assignment for ${student.name} student.") - submitAssignment(assignment, course, student) + Log.d(PREPARATION_TAG,"Submit '${assignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, assignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + 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 it's Assignments Page.") + Log.d(STEP_TAG,"Select '${course.name}' course and navigate to it's Assignments Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectAssignments() - Log.d(STEP_TAG,"Click on ${assignment.name} assignment.") + Log.d(STEP_TAG,"Click on '${assignment.name}' assignment.") assignmentListPage.clickAssignment(assignment) Log.d(STEP_TAG,"Navigate to submission details Comments Tab.") @@ -464,38 +559,38 @@ class AssignmentsE2ETest: StudentTest() { fun testAddFileCommentE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val assignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Submit ${assignment.name} assignment for ${student.name} student.") - submitAssignment(assignment, course, student) + Log.d(PREPARATION_TAG,"Submit '${assignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, assignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() Log.d(STEP_TAG,"Seed a comment attachment upload.") val commentUploadInfo = uploadTextFile( - assignmentId = assignment.id, courseId = course.id, + assignmentId = assignment.id, token = student.token, fileUploadType = FileUploadType.COMMENT_ATTACHMENT ) - commentOnSubmission(student, course, assignment, commentUploadInfo) + SubmissionsApi.commentOnSubmission(course.id, student.token, assignment.id, mutableListOf(commentUploadInfo.id)) - Log.d(STEP_TAG,"Select ${course.name} course and navigate to it's Assignments Page.") + Log.d(STEP_TAG,"Select '${course.name}' course and navigate to it's Assignments Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectAssignments() - Log.d(STEP_TAG,"Click on ${assignment.name} assignment.") + Log.d(STEP_TAG,"Click on '${assignment.name}' assignment.") assignmentListPage.clickAssignment(assignment) - Log.d(STEP_TAG,"Assert that ${commentUploadInfo.fileName} file is displayed as a comment by ${student.name} student.") + Log.d(STEP_TAG,"Assert that '${commentUploadInfo.fileName}' file is displayed as a comment by '${student.name}' student.") assignmentDetailsPage.goToSubmissionDetails() submissionDetailsPage.openComments() submissionDetailsPage.assertCommentAttachmentDisplayed(commentUploadInfo.fileName, student) @@ -510,31 +605,31 @@ class AssignmentsE2ETest: StudentTest() { fun testSubmissionAttemptSelection() { Log.d(PREPARATION_TAG, "Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val pointsTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG, "Select course: ${course.name}.") + Log.d(STEP_TAG, "Select course: '${course.name}'.") dashboardPage.selectCourse(course) Log.d(STEP_TAG, "Navigate to course Assignments Page.") courseBrowserPage.selectAssignments() Log.d(STEP_TAG, "Verify that our assignments are present," + - "along with any grade/date info. Click on assignment ${pointsTextAssignment.name}.") + "along with any grade/date info. Click on assignment '${pointsTextAssignment.name}'.") assignmentListPage.assertHasAssignment(pointsTextAssignment) assignmentListPage.clickAssignment(pointsTextAssignment) - Log.d(PREPARATION_TAG,"Submit assignment: ${pointsTextAssignment.name} for student: ${student.name}.") - submitAssignment(pointsTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Submit assignment: '${pointsTextAssignment.name}' for student: '${student.name}'.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) Log.d(STEP_TAG, "Refresh the page.") assignmentDetailsPage.refresh() @@ -542,8 +637,8 @@ class AssignmentsE2ETest: StudentTest() { Log.d(STEP_TAG, "Assert that when only there is one attempt, the spinner is not displayed.") assignmentDetailsPage.assertNoAttemptSpinner() - Log.d(PREPARATION_TAG,"Generate another submission for assignment: ${pointsTextAssignment.name} for student: ${student.name}.") - submitAssignment(pointsTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Generate another submission for assignment: '${pointsTextAssignment.name}' for student: '${student.name}'.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) Log.d(STEP_TAG, "Refresh the page.") assignmentDetailsPage.refresh() @@ -572,25 +667,25 @@ class AssignmentsE2ETest: StudentTest() { fun testCommentsBelongToSubmissionAttempts() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${course.name}' course.") + val pointsTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Select course: ${course.name}.") + Log.d(STEP_TAG,"Select course: '${course.name}'.") dashboardPage.selectCourse(course) Log.d(STEP_TAG,"Navigate to course Assignments Page.") courseBrowserPage.selectAssignments() - Log.d(STEP_TAG,"Verify that our assignments are present, along with any grade/date info. Click on assignment ${pointsTextAssignment.name}.") + Log.d(STEP_TAG,"Verify that our assignments are present, along with any grade/date info. Click on assignment '${pointsTextAssignment.name}'.") assignmentListPage.assertHasAssignment(pointsTextAssignment) assignmentListPage.clickAssignment(pointsTextAssignment) @@ -606,16 +701,16 @@ class AssignmentsE2ETest: StudentTest() { submissionDetailsPage.assertNoSubmissionEmptyView() Espresso.pressBack() - Log.d(PREPARATION_TAG,"Submit assignment: ${pointsTextAssignment.name} for student: ${student.name}.") - submitAssignment(pointsTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Submit assignment: '${pointsTextAssignment.name}' for student: '${student.name}'.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) Log.d(STEP_TAG, "Refresh the Assignment Details Page. Assert that the assignment's status is submitted and the 'Submission and Rubric' label is displayed.") assignmentDetailsPage.refresh() assignmentDetailsPage.assertStatusSubmitted() assignmentDetailsPage.assertSubmissionAndRubricLabel() - Log.d(PREPARATION_TAG,"Make another submission for assignment: ${pointsTextAssignment.name} for student: ${student.name}.") - val secondSubmissionAttempt = submitAssignment(pointsTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Make another submission for assignment: '${pointsTextAssignment.name}' for student: '${student.name}'.") + val secondSubmissionAttempt = SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) Log.d(STEP_TAG, "Refresh the Assignment Details Page. Assert that the assignment's status is submitted and the 'Submission and Rubric' label is displayed.") assignmentDetailsPage.refresh() @@ -630,7 +725,7 @@ class AssignmentsE2ETest: StudentTest() { assignmentDetailsPage.goToSubmissionDetails() submissionDetailsPage.openComments() - Log.d(STEP_TAG,"Assert that ${secondSubmissionAttempt[0].body} text submission has been displayed as a comment.") + Log.d(STEP_TAG,"Assert that '${secondSubmissionAttempt[0].body}' text submission has been displayed as a comment.") submissionDetailsPage.assertTextSubmissionDisplayedAsComment() val newComment = "Comment for second attempt" @@ -665,27 +760,27 @@ class AssignmentsE2ETest: StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) fun showOnlyLetterGradeOnDashboardAndAssignmentListPageE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + val pointsTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) dashboardPage.waitForRender() Log.d(PREPARATION_TAG,"Grade submission: ${pointsTextAssignment.name} with 12 points.") - gradeSubmission(teacher, course, pointsTextAssignment.id, student, "12") + SubmissionsApi.gradeSubmission(teacher.token, course.id, pointsTextAssignment.id, student.id, postedGrade = "12") Log.d(STEP_TAG, "Refresh the Dashboard page. Assert that the course grade is 80%.") dashboardPage.refresh() dashboardPage.assertCourseGrade(course.name, "80%") Log.d(PREPARATION_TAG, "Update ${course.name} course's settings: Enable restriction for quantitative data.") - var restrictQuantitativeDataMap = mutableMapOf() + val restrictQuantitativeDataMap = mutableMapOf() restrictQuantitativeDataMap["restrict_quantitative_data"] = true CoursesApi.updateCourseSettings(course.id, restrictQuantitativeDataMap) @@ -694,28 +789,28 @@ class AssignmentsE2ETest: StudentTest() { dashboardPage.assertCourseGrade(course.name, "B-") Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val percentageAssignment = createAssignment(course.id, teacher, GradingType.PERCENT, 15.0, 1.days.fromNow.iso8601) + val percentageAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.PERCENT, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Grade submission: ${percentageAssignment.name} with 66% of the maximum points (aka. 10).") - gradeSubmission(teacher, course, percentageAssignment.id, student, "10") + SubmissionsApi.gradeSubmission(teacher.token, course.id, percentageAssignment.id, student.id, postedGrade = "10") Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val letterGradeAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 15.0, 1.days.fromNow.iso8601) + val letterGradeAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Grade submission: ${letterGradeAssignment.name} with C.") - gradeSubmission(teacher, course, letterGradeAssignment.id, student, "C") + SubmissionsApi.gradeSubmission(teacher.token, course.id, letterGradeAssignment.id, student.id, postedGrade = "C") Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val passFailAssignment = createAssignment(course.id, teacher, GradingType.PASS_FAIL, 15.0, 1.days.fromNow.iso8601) + val passFailAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.PASS_FAIL, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Grade submission: ${passFailAssignment.name} with 'Incomplete'.") - gradeSubmission(teacher, course, passFailAssignment.id, student, "Incomplete") + SubmissionsApi.gradeSubmission(teacher.token, course.id, passFailAssignment.id, student.id, postedGrade = "Incomplete") Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val gpaScaleAssignment = createAssignment(course.id, teacher, GradingType.GPA_SCALE, 15.0, 1.days.fromNow.iso8601) + val gpaScaleAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.GPA_SCALE, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Grade submission: ${gpaScaleAssignment.name} with 3.7.") - gradeSubmission(teacher, course, gpaScaleAssignment.id, student, "3.7") + SubmissionsApi.gradeSubmission(teacher.token, course.id, gpaScaleAssignment.id, student.id, postedGrade = "3.7") Log.d(STEP_TAG, "Refresh the Dashboard page to let the newly added submissions and their grades propagate.") dashboardPage.refresh() @@ -821,23 +916,23 @@ class AssignmentsE2ETest: StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.E2E) fun showOnlyLetterGradeOnGradesPageE2E() { Log.d(PREPARATION_TAG, "Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val pointsTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(PREPARATION_TAG, "Grade submission: ${pointsTextAssignment.name} with 12 points.") - gradeSubmission(teacher, course, pointsTextAssignment.id, student, "12") + Log.d(PREPARATION_TAG, "Grade submission: '${pointsTextAssignment.name}' with 12 points.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, pointsTextAssignment.id, student.id, postedGrade = "12") - Log.d(PREPARATION_TAG, "Update ${course.name} course's settings: Enable restriction for quantitative data.") - var restrictQuantitativeDataMap = mutableMapOf() + Log.d(PREPARATION_TAG, "Update '${course.name}' course's settings: Enable restriction for quantitative data.") + val restrictQuantitativeDataMap = mutableMapOf() restrictQuantitativeDataMap["restrict_quantitative_data"] = true CoursesApi.updateCourseSettings(course.id, restrictQuantitativeDataMap) @@ -845,52 +940,34 @@ class AssignmentsE2ETest: StudentTest() { dashboardPage.refresh() dashboardPage.assertCourseGrade(course.name, "B-") - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val percentageAssignment = createAssignment(course.id, teacher, GradingType.PERCENT, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val percentageAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.PERCENT, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG, "Grade submission: ${percentageAssignment.name} with 66% of the maximum points (aka. 10).") - gradeSubmission(teacher, course, percentageAssignment.id, student, "10") + Log.d(PREPARATION_TAG, "Grade submission: '${percentageAssignment.name}' with 66% of the maximum points (aka. 10).") + SubmissionsApi.gradeSubmission(teacher.token, course.id, percentageAssignment.id, student.id, postedGrade = "10") - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val letterGradeAssignment = createAssignment( - course.id, - teacher, - GradingType.LETTER_GRADE, - 15.0, - 1.days.fromNow.iso8601 - ) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val letterGradeAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG, "Grade submission: ${letterGradeAssignment.name} with C.") - gradeSubmission(teacher, course, letterGradeAssignment.id, student, "C") + Log.d(PREPARATION_TAG, "Grade submission: '${letterGradeAssignment.name}' with C.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, letterGradeAssignment.id, student.id, postedGrade = "C") - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val passFailAssignment = createAssignment( - course.id, - teacher, - GradingType.PASS_FAIL, - 15.0, - 1.days.fromNow.iso8601 - ) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val passFailAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.PASS_FAIL, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG, "Grade submission: ${passFailAssignment.name} with 'Incomplete'.") - gradeSubmission(teacher, course, passFailAssignment.id, student, "Incomplete") + Log.d(PREPARATION_TAG, "Grade submission: '${passFailAssignment.name}' with 'Incomplete'.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, passFailAssignment.id, student.id, postedGrade = "Incomplete") - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val gpaScaleAssignment = createAssignment( - course.id, - teacher, - GradingType.GPA_SCALE, - 15.0, - 1.days.fromNow.iso8601 - ) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val gpaScaleAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.GPA_SCALE, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG, "Grade submission: ${gpaScaleAssignment.name} with 3.7.") - gradeSubmission(teacher, course, gpaScaleAssignment.id, student, "3.7") + Log.d(PREPARATION_TAG, "Grade submission: '${gpaScaleAssignment.name}' with 3.7.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, gpaScaleAssignment.id, student.id, postedGrade = "3.7") Log.d(STEP_TAG, "Refresh the Dashboard page to let the newly added submissions and their grades propagate.") dashboardPage.refresh() - Log.d(STEP_TAG, "Select course: ${course.name}. Select 'Grades' menu.") + Log.d(STEP_TAG, "Select course: '${course.name}'. Select 'Grades' menu.") dashboardPage.selectCourse(course) courseBrowserPage.selectGrades() @@ -903,7 +980,7 @@ class AssignmentsE2ETest: StudentTest() { if(isLowResDevice()) courseGradesPage.swipeUp() courseGradesPage.assertAssignmentDisplayed(gpaScaleAssignment.name, "F") - Log.d(PREPARATION_TAG, "Update ${course.name} course's settings: Enable restriction for quantitative data.") + Log.d(PREPARATION_TAG, "Update '${course.name}' course's settings: Enable restriction for quantitative data.") restrictQuantitativeDataMap["restrict_quantitative_data"] = false CoursesApi.updateCourseSettings(course.id, restrictQuantitativeDataMap) @@ -920,95 +997,4 @@ class AssignmentsE2ETest: StudentTest() { courseGradesPage.swipeUp() courseGradesPage.assertAssignmentDisplayed(gpaScaleAssignment.name, "3.7/15 (F)") } - - private fun createAssignment( - courseId: Long, - teacher: CanvasUserApiModel, - gradingType: GradingType, - pointsPossible: Double, - dueAt: String = EMPTY_STRING, - allowedExtensions: List? = null, - assignmentGroupId: Long? = null, - submissionType: List = listOf(SubmissionType.ONLINE_TEXT_ENTRY) - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = submissionType, - gradingType = gradingType, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = dueAt, - allowedExtensions = allowedExtensions, - assignmentGroupId = assignmentGroupId - ) - ) - } - - private fun submitAssignment( - assignment: AssignmentApiModel, - course: CourseApiModel, - student: CanvasUserApiModel - ): List { - return SubmissionsApi.seedAssignmentSubmission( - SubmissionsApi.SubmissionSeedRequest( - assignmentId = assignment.id, - courseId = course.id, - studentToken = student.token, - submissionSeedsList = listOf( - SubmissionsApi.SubmissionSeedInfo( - amount = 1, - submissionType = SubmissionType.ONLINE_TEXT_ENTRY - ) - ) - ) - ) - } - - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - student: CanvasUserApiModel, - postedGrade: String, - excused: Boolean = false - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignment.id, - studentId = student.id, - postedGrade = postedGrade, - excused = excused - ) - } - - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignmentId: Long, - student: CanvasUserApiModel, - postedGrade: String - ) = SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignmentId, - studentId = student.id, - postedGrade = postedGrade, - excused = false - ) - - private fun commentOnSubmission( - student: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - commentUploadInfo: AttachmentApiModel - ) { - SubmissionsApi.commentOnSubmission( - studentToken = student.token, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(commentUploadInfo.id) - ) - } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/BookmarksE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/BookmarksE2ETest.kt index 5a70977edd..e9429327ce 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/BookmarksE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/BookmarksE2ETest.kt @@ -25,9 +25,6 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.AssignmentsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days @@ -58,7 +55,7 @@ class BookmarksE2ETest : StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Preparing an assignment which will be saved as a bookmark.") - val assignment = createAssignment(course, teacher) + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -110,20 +107,4 @@ class BookmarksE2ETest : StudentTest() { bookmarkPage.assertEmptyView() } - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = GradingType.POINTS, - teacherToken = teacher.token, - pointsPossible = 15.0, - dueAt = 1.days.fromNow.iso8601 - ) - ) - } - } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/CollaborationsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/CollaborationsE2ETest.kt index 69fd95887e..6b22a2228c 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/CollaborationsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/CollaborationsE2ETest.kt @@ -3,7 +3,6 @@ package com.instructure.student.ui.e2e import android.util.Log import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory -import com.instructure.canvas.espresso.KnownBug import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData @@ -15,20 +14,15 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test -/** - * Very basic test to verify that the collaborations web page shows up correctly. - * We make no attempt to actually start a collaboration. - * This test could break if changes are made to the web page that we bring up. - */ @HiltAndroidTest class CollaborationsE2ETest: StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @E2E @Test - @KnownBug("https://instructure.atlassian.net/browse/VICE-3157") @TestMetaData(Priority.MANDATORY, FeatureCategory.COLLABORATIONS, TestCategory.E2E) fun testCollaborationsE2E() { @@ -37,24 +31,24 @@ class CollaborationsE2ETest: StudentTest() { val student = data.studentsList[0] val course = data.coursesList[0] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Navigate to ${course.name} course's Collaborations Page.") + Log.d(STEP_TAG,"Navigate to '${course.name}' course's Collaborations Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectCollaborations() Log.d(STEP_TAG,"Verify that various elements of the web page are present.") CollaborationsPage.assertCurrentCollaborationsHeaderPresent() - //On some screen size, this spinner does not displayed at all, instead of it, - //there is a button on the top-right corner with the 'Start a new Collaboration' text - //and clicking on it will 'expand' and display this spinner. - //However, there is a bug (see link in this @KnownBug annotation) which is about the button not displayed on some screen size - //So this test will breaks until it this ticket will be fixed. + Log.d(STEP_TAG, "Assert that the 'Start a New Collaboration' button is displayed.") CollaborationsPage.assertStartANewCollaborationPresent() - CollaborationsPage.assertGoogleDocsChoicePresent() - CollaborationsPage.assertGoogleDocsExplanationPresent() + + Log.d(STEP_TAG, "Assert that within the selector, the 'Google Docs' has been selected as the default value.") + CollaborationsPage.assertGoogleDocsChoicePresentAsDefaultOption() + + Log.d(STEP_TAG, "Assert that the warning section (under the selector) of Google Docs has been displayed.") + CollaborationsPage.assertGoogleDocsWarningDescriptionPresent() } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ConferencesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ConferencesE2ETest.kt index dcc641e94d..b79cfa0ae7 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ConferencesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ConferencesE2ETest.kt @@ -53,15 +53,11 @@ class ConferencesE2ETest: StudentTest() { val testConferenceTitle = "E2E test conference" val testConferenceDescription = "Nightly E2E Test conference description" Log.d(PREPARATION_TAG,"Create a conference with '$testConferenceTitle' title and '$testConferenceDescription' description.") - ConferencesApi.createCourseConference(teacher.token, - testConferenceTitle, testConferenceDescription,"BigBlueButton",false,70, - listOf(student.id),course.id) + ConferencesApi.createCourseConference(course.id, teacher.token, testConferenceTitle, testConferenceDescription, recipientUserIds = listOf(student.id)) val testConferenceTitle2 = "E2E test conference 2" val testConferenceDescription2 = "Nightly E2E Test conference description 2" - ConferencesApi.createCourseConference(teacher.token, - testConferenceTitle2, testConferenceDescription2,"BigBlueButton",true,120, - listOf(student.id),course.id) + ConferencesApi.createCourseConference(course.id, teacher.token, testConferenceTitle2, testConferenceDescription2, longRunning = true, duration = 120, recipientUserIds = listOf(student.id)) Log.d(STEP_TAG,"Refresh the page. Assert that $testConferenceTitle conference is displayed on the Conference List Page with the corresponding status.") refresh() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt index 9770e01244..4b11b77837 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt @@ -50,10 +50,7 @@ class DashboardE2ETest : StudentTest() { val course2 = data.coursesList[1] Log.d(PREPARATION_TAG, "Seed an Inbox conversation via API.") - ConversationsApi.createConversation( - token = teacher.token, - recipients = listOf(student.id.toString()) - ) + ConversationsApi.createConversation(teacher.token, listOf(student.id.toString())) Log.d(PREPARATION_TAG,"Seed some group info.") val groupCategory = GroupsApi.createCourseGroupCategory(data.coursesList[0].id, teacher.token) 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 bf6bbeca3b..aa5efe83d0 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 @@ -25,8 +25,6 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData 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.student.ui.utils.StudentTest @@ -52,51 +50,51 @@ class DiscussionsE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seed a discussion topic.") - val topic1 = createDiscussion(course, teacher) + Log.d(PREPARATION_TAG,"Seed a discussion topic for '${course.name}' course.") + val topic1 = DiscussionTopicsApi.createDiscussion(course.id, teacher.token) - Log.d(PREPARATION_TAG,"Seed another discussion topic.") - val topic2 = createDiscussion(course, teacher) + Log.d(PREPARATION_TAG,"Seed another discussion topic for '${course.name}' course.") + val topic2 = DiscussionTopicsApi.createDiscussion(course.id, teacher.token) - Log.d(STEP_TAG,"Seed an announcement for ${course.name} course.") - val announcement = createAnnouncement(course, teacher) + Log.d(STEP_TAG,"Seed an announcement for '${course.name}' course.") + val announcement = DiscussionTopicsApi.createAnnouncement(course.id, teacher.token) - Log.d(STEP_TAG,"Seed another announcement for ${course.name} course.") - val announcement2 = createAnnouncement(course, teacher) + Log.d(STEP_TAG,"Seed another announcement for '${course.name}' course.") + val announcement2 = DiscussionTopicsApi.createAnnouncement(course.id, teacher.token) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) - dashboardPage.waitForRender() - Log.d(STEP_TAG,"Select course: ${course.name}.") + Log.d(STEP_TAG,"Wait for the Dashboard Page to be rendered. Select course: '${course.name}'.") + dashboardPage.waitForRender() dashboardPage.selectCourse(course) - Log.d(STEP_TAG,"Verify that the Discussions and Announcements 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") - Log.d(STEP_TAG,"Navigate to Announcements Page. Assert that both ${announcement.title} and ${announcement2.title} announcements are displayed.") + Log.d(STEP_TAG,"Navigate to Announcements Page. Assert that both '${announcement.title}' and '${announcement2.title}' announcements are displayed.") courseBrowserPage.selectAnnouncements() discussionListPage.assertTopicDisplayed(announcement.title) discussionListPage.assertTopicDisplayed(announcement2.title) - Log.d(STEP_TAG,"Select ${announcement.title} announcement and assert if the details page is displayed.") + Log.d(STEP_TAG,"Select '${announcement.title}' announcement and assert if the Discussion Details Page is displayed.") discussionListPage.selectTopic(announcement.title) discussionDetailsPage.assertTitleText(announcement.title) - Log.d(STEP_TAG,"Navigate back.") + Log.d(STEP_TAG,"Navigate back to the Discussion List Page.") Espresso.pressBack() - Log.d(STEP_TAG,"Click on the 'Search' button and search for ${announcement2.title}. announcement.") + Log.d(STEP_TAG,"Click on the 'Search' button and search for '${announcement2.title}'. announcement.") 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.") + 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() discussionListPage.assertTopicDisplayed(announcement2.title) 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.") + Log.d(STEP_TAG,"Clear the search input field and assert that both announcements, '${announcement.title}' and '${announcement2.title}' has been displayed.") discussionListPage.searchable.clickOnClearSearchButton() discussionListPage.waitForDiscussionTopicToDisplay(announcement.title) discussionListPage.assertTopicDisplayed(announcement2.title) @@ -107,22 +105,22 @@ class DiscussionsE2ETest: StudentTest() { Log.d(STEP_TAG,"Navigate to Discussions Page.") courseBrowserPage.selectDiscussions() - Log.d(STEP_TAG,"Select ${topic1.title} discussion and assert if the details page is displayed and there is no reply for the discussion yet.") + Log.d(STEP_TAG,"Select '${topic1.title}' discussion and assert if the details page is displayed and there is no reply for the discussion yet.") discussionListPage.assertTopicDisplayed(topic1.title) discussionListPage.selectTopic(topic1.title) discussionDetailsPage.assertTitleText(topic1.title) discussionDetailsPage.assertNoRepliesDisplayed() - Log.d(STEP_TAG,"Navigate back to Discussions Page.") - Espresso.pressBack() // Back to discussion list + Log.d(STEP_TAG,"Navigate back to Discussion List Page.") + Espresso.pressBack() - Log.d(STEP_TAG,"Select ${topic1.title} discussion and assert if the details page is displayed and there is no reply for the discussion yet.") + Log.d(STEP_TAG,"Select '${topic1.title}' discussion and assert if the details page is displayed and there is no reply for the discussion yet.") discussionListPage.assertTopicDisplayed(topic2.title) discussionListPage.selectTopic(topic2.title) discussionDetailsPage.assertTitleText(topic2.title) discussionDetailsPage.assertNoRepliesDisplayed() - Log.d(STEP_TAG,"Navigate back to Discussions Page.") + Log.d(STEP_TAG,"Navigate back to Discussion List Page.") Espresso.pressBack() val newTopicName = "Do we really need discussions?" @@ -131,11 +129,11 @@ class DiscussionsE2ETest: StudentTest() { discussionListPage.createDiscussionTopic(newTopicName, newTopicDescription) sleep(2000) // Allow some time for creation to propagate - Log.d(STEP_TAG,"Assert that $newTopicName topic has been created successfully with 0 reply count.") + Log.d(STEP_TAG,"Assert that '$newTopicName' topic has been created successfully with 0 reply count.") discussionListPage.assertTopicDisplayed(newTopicName) discussionListPage.assertReplyCount(newTopicName, 0) - Log.d(STEP_TAG,"Select $newTopicName topic and assert that there is no reply on the details page as well.") + Log.d(STEP_TAG,"Select '$newTopicName' topic and assert that there is no reply on the details page as well.") discussionListPage.selectTopic(newTopicName) discussionDetailsPage.assertNoRepliesDisplayed() @@ -147,7 +145,7 @@ class DiscussionsE2ETest: StudentTest() { Log.d(STEP_TAG,"Assert the the previously sent reply ($replyMessage) is displayed on the details page.") discussionDetailsPage.assertRepliesDisplayed() - Log.d(STEP_TAG,"Navigate back to Discussions Page.") + Log.d(STEP_TAG,"Navigate back to Discussion List Page.") Espresso.pressBack() Log.d(STEP_TAG,"Refresh the page. Assert that the previously sent reply has been counted, and there are no unread replies.") @@ -158,22 +156,6 @@ class DiscussionsE2ETest: StudentTest() { Log.d(STEP_TAG, "Assert that the due date is the current date (in the expected format).") val currentDate = getCurrentDateInCanvasFormat() discussionListPage.assertDueDate(newTopicName, currentDate) - } - private fun createAnnouncement( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = DiscussionTopicsApi.createAnnouncement( - courseId = course.id, - token = teacher.token - ) - - private fun createDiscussion( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = DiscussionTopicsApi.createDiscussion( - courseId = course.id, - token = teacher.token - ) } \ No newline at end of file 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 b3cdea4a7b..15c3c673a3 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 @@ -26,20 +26,19 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvasapi2.managers.DiscussionManager import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.DiscussionEntry import com.instructure.canvasapi2.utils.weave.awaitApiResponse import com.instructure.canvasapi2.utils.weave.catch 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.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.GradingType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.Randomizer +import com.instructure.dataseeding.util.days +import com.instructure.dataseeding.util.fromNow +import com.instructure.dataseeding.util.iso8601 import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.ViewUtils import com.instructure.student.ui.utils.seedData @@ -52,6 +51,7 @@ import java.io.FileWriter @HiltAndroidTest class FilesE2ETest: StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -67,8 +67,8 @@ class FilesE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val assignment = createAssignment(course, teacher) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), allowedExtensions = listOf("txt")) Log.d(PREPARATION_TAG, "Seed a text file.") val submissionUploadInfo = uploadTextFile( @@ -78,20 +78,20 @@ class FilesE2ETest: StudentTest() { fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION ) - Log.d(PREPARATION_TAG,"Submit ${assignment.name} assignment for ${student.name} student.") - submitAssignment(course, assignment, submissionUploadInfo, student) + Log.d(PREPARATION_TAG,"Submit '${assignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.submitCourseAssignment(course.id, student.token, assignment.id, SubmissionType.ONLINE_UPLOAD, fileIds = mutableListOf(submissionUploadInfo.id)) - Log.d(STEP_TAG,"Seed a comment attachment upload.") + Log.d(STEP_TAG,"Seed a comment attachment (file) upload.") val commentUploadInfo = uploadTextFile( assignmentId = assignment.id, courseId = course.id, token = student.token, fileUploadType = FileUploadType.COMMENT_ATTACHMENT ) - commentOnSubmission(student, course, assignment, commentUploadInfo) + SubmissionsApi.commentOnSubmission(course.id, student.token, assignment.id, mutableListOf(commentUploadInfo.id)) - Log.d(STEP_TAG,"Seed a discussion for ${course.name} course.") - val discussionTopic = createDiscussion(course, student) + Log.d(STEP_TAG,"Seed a discussion for '${course.name}' course.") + val discussionTopic = DiscussionTopicsApi.createDiscussion(course.id, student.token) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -102,7 +102,7 @@ class FilesE2ETest: StudentTest() { Randomizer.randomTextFileName(Environment.getExternalStorageDirectory().absolutePath)) .apply { createNewFile() } - Log.d(STEP_TAG,"Add some random content to the ${discussionAttachmentFile.name} file.") + Log.d(STEP_TAG,"Add some random content to the '${discussionAttachmentFile.name}' file.") FileWriter(discussionAttachmentFile, true).apply { write(Randomizer.randomTextFileContents()) flush() @@ -111,7 +111,7 @@ class FilesE2ETest: StudentTest() { Log.d(PREPARATION_TAG,"Use real API (rather than seeding) to create a reply to our discussion that contains an attachment.") tryWeave { - awaitApiResponse { + awaitApiResponse { DiscussionManager.postToDiscussionTopic( canvasContext = CanvasContext.emptyCourseContext(id = course.id), topicId = discussionTopic.id, @@ -124,22 +124,22 @@ class FilesE2ETest: StudentTest() { Log.v(PREPARATION_TAG, "Discussion post error: $it") } - Log.d(STEP_TAG,"Navigate to 'Files' menu in user left-side menubar.") + Log.d(STEP_TAG,"Navigate to 'Files' menu in user left-side menu bar.") leftSideNavigationDrawerPage.clickFilesMenu() Log.d(STEP_TAG,"Assert that there is a directory called 'Submissions' is displayed.") fileListPage.assertItemDisplayed("Submissions") - Log.d(STEP_TAG,"Select 'Submissions' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") + Log.d(STEP_TAG,"Select 'Submissions' directory. Assert that '${discussionAttachmentFile.name}' file is displayed on the File List Page.") fileListPage.selectItem("Submissions") - Log.d(STEP_TAG,"Assert that ${course.name} course is displayed.") + Log.d(STEP_TAG,"Assert that '${course.name}' course is displayed.") fileListPage.assertItemDisplayed(course.name) - Log.d(STEP_TAG,"Select ${course.name} course.") + Log.d(STEP_TAG,"Select '${course.name}' course.") fileListPage.selectItem(course.name) - Log.d(STEP_TAG,"Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") + Log.d(STEP_TAG,"Assert that '${discussionAttachmentFile.name}' file is displayed on the File List Page.") fileListPage.assertItemDisplayed(submissionUploadInfo.fileName) Log.d(STEP_TAG,"Navigate back to File List Page.") @@ -148,37 +148,37 @@ 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,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") + 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) Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Select ${course.name} course.") + Log.d(STEP_TAG,"Select '${course.name}' course.") dashboardPage.selectCourse(course) Log.d(STEP_TAG,"Navigate to Assignments Page.") courseBrowserPage.selectAssignments() - Log.d(STEP_TAG,"Click on ${assignment.name} assignment.") + Log.d(STEP_TAG,"Click on '${assignment.name}' assignment.") assignmentListPage.clickAssignment(assignment) Log.d(STEP_TAG,"Navigate to Submission Details Page and open Files Tab.") assignmentDetailsPage.goToSubmissionDetails() submissionDetailsPage.openFiles() - Log.d(STEP_TAG,"Assert that ${submissionUploadInfo.fileName} file has been displayed.") + Log.d(STEP_TAG,"Assert that '${submissionUploadInfo.fileName}' file has been displayed.") submissionDetailsPage.assertFileDisplayed(submissionUploadInfo.fileName) - Log.d(STEP_TAG,"Open Comments Tab. Assert that ${commentUploadInfo.fileName} file is displayed as a comment by ${student.name} student.") + Log.d(STEP_TAG,"Open Comments Tab. Assert that '${commentUploadInfo.fileName}' file is displayed as a comment by '${student.name}' student.") submissionDetailsPage.openComments() submissionDetailsPage.assertCommentAttachmentDisplayed(commentUploadInfo.fileName, student) Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(4) - Log.d(STEP_TAG,"Navigate to 'Files' menu in user left-side menubar.") + Log.d(STEP_TAG,"Navigate to 'Files' menu in user left-side menu bar.") leftSideNavigationDrawerPage.clickFilesMenu() Log.d(STEP_TAG,"Assert that there is a directory called 'unfiled' is displayed.") @@ -194,18 +194,18 @@ class FilesE2ETest: StudentTest() { fileListPage.assertItemNotDisplayed("unfiled") fileListPage.searchable.pressSearchBackButton() - Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") + 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) val newFileName = "newTextFileName.txt" - Log.d(STEP_TAG,"Rename ${discussionAttachmentFile.name} file to: $newFileName.") + Log.d(STEP_TAG,"Rename '${discussionAttachmentFile.name}' file to: '$newFileName'.") fileListPage.renameFile(discussionAttachmentFile.name, newFileName) - Log.d(STEP_TAG,"Assert that the file is displayed with it's new file name: $newFileName.") + Log.d(STEP_TAG,"Assert that the file is displayed with it's new file name: '$newFileName'.") fileListPage.assertItemDisplayed(newFileName) - Log.d(STEP_TAG,"Delete $newFileName file.") + Log.d(STEP_TAG,"Delete '$newFileName' file.") fileListPage.deleteFile(newFileName) Log.d(STEP_TAG,"Assert that empty view is displayed after deletion.") @@ -224,54 +224,4 @@ class FilesE2ETest: StudentTest() { Log.d(STEP_TAG,"Assert that there is a folder called '$testFolderName' is displayed.") fileListPage.assertItemDisplayed(testFolderName) } - - private fun commentOnSubmission( - student: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - commentUploadInfo: AttachmentApiModel - ) { - SubmissionsApi.commentOnSubmission( - studentToken = student.token, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(commentUploadInfo.id) - ) - } - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = false, - submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), - allowedExtensions = listOf("txt"), - teacherToken = teacher.token - ) - ) - - private fun submitAssignment( - course: CourseApiModel, - assignment: AssignmentApiModel, - submissionUploadInfo: AttachmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_UPLOAD, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(submissionUploadInfo.id), - studentToken = student.token - ) - } - - private fun createDiscussion( - course: CourseApiModel, - student: CanvasUserApiModel - ) = DiscussionTopicsApi.createDiscussion( - courseId = course.id, - token = student.token - ) } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/GradesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/GradesE2ETest.kt index c176601e99..253bc5dcdf 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/GradesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/GradesE2ETest.kt @@ -12,9 +12,6 @@ import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.QuizzesApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.QuizAnswer import com.instructure.dataseeding.model.QuizQuestion @@ -31,6 +28,7 @@ import org.junit.Test @HiltAndroidTest class GradesE2ETest: StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -46,9 +44,9 @@ class GradesE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val assignment = createAssignment(course, teacher) - val assignment2 = createAssignment(course, teacher) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, gradingType = GradingType.PERCENT, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601) + val assignment2 = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, gradingType = GradingType.PERCENT, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601) Log.d(PREPARATION_TAG,"Create a quiz with some questions.") val quizQuestions = makeQuizQuestions() @@ -60,7 +58,7 @@ class GradesE2ETest: StudentTest() { tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Select ${course.name} course.") + Log.d(STEP_TAG,"Select '${course.name}' course.") dashboardPage.selectCourse(course) Log.d(STEP_TAG,"Navigate to Grades Page.") @@ -71,7 +69,7 @@ class GradesE2ETest: StudentTest() { val assignmentMatcher = withText(assignment.name) val quizMatcher = withText(quiz.title) - Log.d(STEP_TAG,"Refresh the page. Assert that the ${assignment.name} assignment and ${quiz.title} quiz are displayed and there is no grade for them.") + Log.d(STEP_TAG,"Refresh the page. Assert that the '${assignment.name}' assignment and '${quiz.title}' quiz are displayed and there is no grade for them.") courseGradesPage.refresh() courseGradesPage.assertItemDisplayed(assignmentMatcher) courseGradesPage.assertGradeNotDisplayed(assignmentMatcher) @@ -81,7 +79,7 @@ class GradesE2ETest: StudentTest() { Log.d(STEP_TAG,"Check in the 'What-If Score' checkbox.") courseGradesPage.toggleWhatIf() - Log.d(STEP_TAG,"Enter '12' as a what-if grade for ${assignment.name} assignment.") + Log.d(STEP_TAG,"Enter '12' as a what-if grade for '${assignment.name}' assignment.") courseGradesPage.enterWhatIfGrade(assignmentMatcher, "12") Log.d(STEP_TAG,"Assert that 'Total Grade' contains the score '80%'.") @@ -94,10 +92,10 @@ class GradesE2ETest: StudentTest() { courseGradesPage.assertTotalGrade(withText(R.string.noGradeText)) Log.d(PREPARATION_TAG,"Seed a submission for '${assignment.name}' assignment.") - submitAssignment(course, assignment, student) + SubmissionsApi.submitCourseAssignment(course.id, student.token, assignment.id, SubmissionType.ONLINE_TEXT_ENTRY) Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${assignment.name}' assignment.") - gradeSubmission(teacher, course, assignment, student, "9",false) + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment.id, student.id, postedGrade = "9") Log.d(STEP_TAG,"Refresh the page. Assert that the assignment's score is '60%'.") courseGradesPage.refresh() @@ -114,10 +112,10 @@ class GradesE2ETest: StudentTest() { courseGradesPage.refreshUntilAssertTotalGrade(containsTextCaseInsensitive("60")) Log.d(PREPARATION_TAG,"Seed a submission for '${assignment2.name}' assignment.") - submitAssignment(course, assignment2, student) + SubmissionsApi.submitCourseAssignment(course.id, student.token, assignment2.id, SubmissionType.ONLINE_TEXT_ENTRY) Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${assignment2.name}' assignment.") - gradeSubmission(teacher, course, assignment2, student, "10", excused = false) + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment2.id, student.id, postedGrade = "10") Log.d(STEP_TAG,"Assert that we can see the correct score at the '${assignment2.name}' assignment (66.67%) and at the total score as well (63.33%).") courseGradesPage.refresh() @@ -128,13 +126,13 @@ class GradesE2ETest: StudentTest() { courseGradesPage.refreshUntilAssertTotalGrade(containsTextCaseInsensitive("63.33")) Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${assignment.name}' assignment.") - gradeSubmission(teacher, course, assignment, student, excused = true) + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment.id, student.id, excused = true) courseGradesPage.refresh() Log.d(STEP_TAG,"Assert that we can see the correct score (66.67%).") courseGradesPage.refreshUntilAssertTotalGrade(containsTextCaseInsensitive("66.67")) - gradeSubmission(teacher, course, assignment, student, "9",false) + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment.id, student.id, postedGrade = "9") courseGradesPage.refresh() Log.d(STEP_TAG,"Assert that we can see the correct score (63.33%).") @@ -194,54 +192,4 @@ class GradesE2ETest: StudentTest() { ) ) - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = true, - dueAt = 1.days.fromNow.iso8601, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - teacherToken = teacher.token, - gradingType = GradingType.PERCENT, - pointsPossible = 15.0 - ) - ) - } - - private fun submitAssignment( - course: CourseApiModel, - assignment: AssignmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_TEXT_ENTRY, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(), - studentToken = student.token - ) - } - - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - student: CanvasUserApiModel, - postedGrade: String? = null, - excused: Boolean, - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignment.id, - studentId = student.id, - postedGrade = postedGrade, - excused = excused - ) - } - } 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 6be6ca1fed..e14c4b2683 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 @@ -28,7 +28,6 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.ConversationsApi import com.instructure.dataseeding.api.GroupsApi -import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData @@ -46,6 +45,7 @@ class InboxE2ETest: StudentTest() { @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] @@ -53,9 +53,11 @@ class InboxE2ETest: StudentTest() { val student1 = data.studentsList[0] val student2 = data.studentsList[1] + Log.d(PREPARATION_TAG, "Create a course group category and a group based on that category.") 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}.") + + 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) @@ -68,19 +70,19 @@ class InboxE2ETest: StudentTest() { 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(PREPARATION_TAG,"Seed an email from the teacher to '${student1.name}' and '${student2.name}' students.") + val seededConversation = ConversationsApi.createConversation(teacher.token, listOf(student1.id.toString(), student2.id.toString()))[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. Assert that is has not been starred already.") + Log.d(STEP_TAG,"Select '${seededConversation.subject}' conversation. Assert that is has not been starred already.") inboxPage.openConversation(seededConversation) inboxConversationPage.assertNotStarred() - Log.d(STEP_TAG,"Toggle Starred to mark ${seededConversation.subject} conversation as favourite. Assert that it has became starred.") + Log.d(STEP_TAG,"Toggle Starred to mark '${seededConversation.subject}' conversation as favourite. Assert that it has became starred.") inboxConversationPage.toggleStarred() inboxConversationPage.assertStarred() @@ -88,25 +90,25 @@ class InboxE2ETest: StudentTest() { Espresso.pressBack() // To main inbox page inboxPage.assertConversationStarred(seededConversation.subject) - Log.d(STEP_TAG,"Select ${seededConversation.subject} conversation. Mark as Unread by clicking on the 'More Options' menu, 'Mark as Unread' menu point.") + Log.d(STEP_TAG,"Select '${seededConversation.subject}' conversation. Mark as Unread by clicking on the 'More Options' menu, 'Mark as Unread' menu point.") inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.GONE) inboxPage.openConversation(seededConversation) inboxConversationPage.markUnread() //After select 'Mark as Unread', we will be navigated back to Inbox Page - Log.d(STEP_TAG,"Assert that ${seededConversation.subject} conversation has been marked as unread.") + Log.d(STEP_TAG,"Assert that '${seededConversation.subject}' conversation has been marked as unread.") inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.VISIBLE) - Log.d(STEP_TAG,"Select ${seededConversation.subject} conversation. Archive it by clicking on the 'More Options' menu, 'Archive' menu point.") + Log.d(STEP_TAG,"Select '${seededConversation.subject}' conversation. Archive it by clicking on the 'More Options' menu, 'Archive' menu point.") inboxPage.openConversation(seededConversation) inboxConversationPage.archive() //After select 'Archive', we will be navigated back to Inbox Page - Log.d(STEP_TAG,"Assert that ${seededConversation.subject} conversation has removed from 'All' tab.") //TODO: Discuss this logic if it's ok if we don't show Archived messages on 'All' tab... + Log.d(STEP_TAG,"Assert that '${seededConversation.subject}' conversation has removed from 'All' tab.") //TODO: Discuss this logic if it's ok if we don't show Archived messages on 'All' tab... inboxPage.assertConversationNotDisplayed(seededConversation) 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.") + Log.d(STEP_TAG,"Assert that '${seededConversation.subject}' conversation is displayed by the 'Archived' filter.") inboxPage.assertConversationDisplayed(seededConversation) Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation. Assert that the selected number of conversations on the toolbar is 1." + @@ -114,18 +116,17 @@ class InboxE2ETest: StudentTest() { inboxPage.selectConversation(seededConversation) inboxPage.assertSelectedConversationNumber("1") inboxPage.clickUnArchive() - inboxPage.assertInboxEmpty() inboxPage.assertConversationNotDisplayed(seededConversation.subject) sleep(2000) - Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that ${seededConversation.subject} conversation is displayed.") + Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that '${seededConversation.subject}' conversation is displayed.") inboxPage.filterInbox("Inbox") inboxPage.assertConversationDisplayed(seededConversation.subject) - 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.") + Log.d(STEP_TAG, "Select the conversation '${seededConversation.subject}' and unstar it." + + "Assert that the selected number of conversations on the toolbar is 1 and the conversation is not starred.") inboxPage.selectConversations(listOf(seededConversation.subject)) inboxPage.assertSelectedConversationNumber("1") inboxPage.clickUnstar() @@ -134,7 +135,7 @@ class InboxE2ETest: StudentTest() { inboxPage.assertConversationNotStarred(seededConversation.subject) } - Log.d(STEP_TAG, "Select the conversations (${seededConversation.subject} and archive it. Assert that it has not displayed in the 'INBOX' scope.") + Log.d(STEP_TAG, "Select the conversation '${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) @@ -153,7 +154,7 @@ class InboxE2ETest: StudentTest() { inboxPage.filterInbox("Starred") inboxPage.assertConversationNotDisplayed(seededConversation.subject) - Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that ${seededConversation.subject} conversation is NOT displayed because it is archived yet.") + 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) @@ -192,9 +193,11 @@ class InboxE2ETest: StudentTest() { val student1 = data.studentsList[0] val student2 = data.studentsList[1] + Log.d(PREPARATION_TAG, "Create a course group category and a group based on that category.") 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}.") + + 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) @@ -207,8 +210,8 @@ class InboxE2ETest: StudentTest() { 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(PREPARATION_TAG,"Seed an email from the teacher to '${student1.name}' and '${student2.name}' students.") + val seededConversation = ConversationsApi.createConversation(teacher.token, listOf(student1.id.toString(), student2.id.toString()))[0] Log.d(STEP_TAG,"Refresh the page. Assert that there is a conversation and it is the previously seeded one.") refresh() @@ -220,7 +223,7 @@ class InboxE2ETest: StudentTest() { val newMessageSubject = "Hey There" val newMessage = "Just checking in" - Log.d(STEP_TAG,"Create a new message with subject: $newMessageSubject, and message: $newMessage") + 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.") @@ -231,7 +234,7 @@ class InboxE2ETest: StudentTest() { val newGroupMessageSubject = "Group Message" val newGroupMessage = "Testing Group ${group.name}" - Log.d(STEP_TAG,"Create a new message with subject: $newGroupMessageSubject, and message: $newGroupMessage") + 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.") @@ -243,7 +246,7 @@ class InboxE2ETest: StudentTest() { inboxPage.goToDashboard() dashboardPage.waitForRender() - Log.d(STEP_TAG,"Log out with ${student1.name} student.") + 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}.") @@ -256,13 +259,13 @@ class InboxE2ETest: StudentTest() { inboxPage.assertConversationDisplayed(newMessageSubject) inboxPage.assertConversationDisplayed("Group Message") - Log.d(STEP_TAG,"Select $newGroupMessageSubject conversation.") + 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.") + Log.d(STEP_TAG,"Delete '$newReplyMessage' reply and assert is has been deleted.") inboxConversationPage.deleteMessage(newReplyMessage) inboxConversationPage.assertMessageNotDisplayed(newReplyMessage) @@ -272,7 +275,7 @@ class InboxE2ETest: StudentTest() { inboxPage.assertConversationDisplayed(seededConversation) inboxPage.assertConversationDisplayed("Group Message") - Log.d(STEP_TAG, "Navigate to 'INBOX' scope and seledct '$newGroupMessageSubject' conversation.") + Log.d(STEP_TAG, "Navigate to 'INBOX' scope and select '$newGroupMessageSubject' conversation.") inboxPage.filterInbox("Inbox") inboxPage.selectConversation(newGroupMessageSubject) @@ -286,6 +289,7 @@ class InboxE2ETest: StudentTest() { @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] @@ -293,9 +297,11 @@ class InboxE2ETest: StudentTest() { val student1 = data.studentsList[0] val student2 = data.studentsList[1] + Log.d(PREPARATION_TAG, "Create a course group category and a group based on that category.") 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}.") + + 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) @@ -308,8 +314,8 @@ class InboxE2ETest: StudentTest() { 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(PREPARATION_TAG,"Seed an email from the teacher to '${student1.name}' and '${student2.name}' students.") + val seededConversation = ConversationsApi.createConversation(teacher.token, listOf(student1.id.toString(), student2.id.toString()))[0] Log.d(STEP_TAG,"Refresh the page. Assert that there is a conversation and it is the previously seeded one.") refresh() @@ -405,7 +411,7 @@ class InboxE2ETest: StudentTest() { inboxPage.openConversationWithRecipients(recipientList) inboxConversationPage.assertMessageDisplayed(questionText) - Log.d(STEP_TAG,"Log out with ${student.name} student.") + Log.d(STEP_TAG,"Log out with '${student.name}' student.") Espresso.pressBack() leftSideNavigationDrawerPage.logout() @@ -423,13 +429,4 @@ class InboxE2ETest: StudentTest() { inboxConversationPage.assertMessageDisplayed(questionText) inboxConversationPage.assertNoSubjectDisplayed() } - - private fun createConversation( - teacher: CanvasUserApiModel, - student1: CanvasUserApiModel, - student2: CanvasUserApiModel - ) = ConversationsApi.createConversation( - token = teacher.token, - recipients = listOf(student1.id.toString(), student2.id.toString()) - ) } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt index c9d3d7b60a..12526c1bac 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt @@ -39,6 +39,7 @@ import org.junit.Test @HiltAndroidTest class LoginE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -59,7 +60,7 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertDashboardPageDisplayed(student1) - Log.d(STEP_TAG, "Log out with ${student1.name} student.") + 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}.") @@ -83,15 +84,15 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() - Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that ${student1.name} and ${student2.name} students are displayed within the previous login section.") + Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that '${student1.name}' and '${student2.name}' students are displayed within the previous login section.") loginLandingPage.assertDisplaysPreviousLogins() loginLandingPage.assertPreviousLoginUserDisplayed(student1.name) loginLandingPage.assertPreviousLoginUserDisplayed(student2.name) - Log.d(STEP_TAG, "Remove ${student1.name} student from the previous login section.") + Log.d(STEP_TAG, "Remove '${student1.name}' student from the previous login section.") loginLandingPage.removeUserFromPreviousLogins(student1.name) - Log.d(STEP_TAG, "Login with the previous user, ${student2.name}, with one click, by clicking on the user's name on the bottom.") + Log.d(STEP_TAG, "Login with the previous user, '${student2.name}', with one click, by clicking on the user's name on the bottom.") loginLandingPage.loginWithPreviousUser(student2) Log.d(STEP_TAG, "Assert that the Dashboard Page is the landing page and it is loaded successfully.") @@ -100,18 +101,17 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() - Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that ${student1.name} and ${student2.name} students are displayed within the previous login section.") + Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that '${student1.name}' and '${student2.name}' students are displayed within the previous login section.") loginLandingPage.assertDisplaysPreviousLogins() loginLandingPage.assertPreviousLoginUserDisplayed(student2.name) - Log.d(STEP_TAG, "Remove ${student2.name} student from the previous login section.") + Log.d(STEP_TAG, "Remove '${student2.name}' student from the previous login section.") loginLandingPage.removeUserFromPreviousLogins(student2.name) - Log.d(STEP_TAG, "Assert that none of the students, ${student1.name} and ${student2.name} are displayed and not even the 'Previous Logins' label is displayed.") + Log.d(STEP_TAG, "Assert that none of the students, '${student1.name}' and '${student2.name}' are displayed and not even the 'Previous Logins' label is displayed.") loginLandingPage.assertPreviousLoginUserNotExist(student1.name) loginLandingPage.assertPreviousLoginUserNotExist(student2.name) loginLandingPage.assertNotDisplaysPreviousLogins() - } @E2E @@ -138,7 +138,6 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertDashboardPageDisplayed(student2) - } @E2E @@ -162,37 +161,37 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") loginWithUser(student) - Log.d(STEP_TAG,"Validate ${student.name} user's role as a Student.") + Log.d(STEP_TAG,"Validate '${student.name}' user's role as a Student.") validateUserAndRole(student, course, "Student") Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Log out with ${student.name} student.") + Log.d(STEP_TAG,"Log out with '${student.name}' student.") leftSideNavigationDrawerPage.logout() Log.d(STEP_TAG,"Login with user: ${teacher.name}, login id: ${teacher.loginId}.") loginWithUser(teacher, true) - Log.d(STEP_TAG,"Validate ${teacher.name} user's role as a Teacher.") + Log.d(STEP_TAG,"Validate '${teacher.name}' user's role as a Teacher.") validateUserAndRole(teacher, course, "Teacher") Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Log out with ${teacher.name} teacher.") + Log.d(STEP_TAG,"Log out with '${teacher.name}' teacher.") leftSideNavigationDrawerPage.logout() Log.d(STEP_TAG,"Login with user: ${ta.name}, login id: ${ta.loginId}.") loginWithUser(ta, true) - Log.d(STEP_TAG,"Validate ${ta.name} user's role as a TA.") + Log.d(STEP_TAG,"Validate '${ta.name}' user's role as a TA (Teacher Assistant).") validateUserAndRole(ta, course, "TA") Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Log out with ${ta.name} teacher assistant.") + Log.d(STEP_TAG,"Log out with '${ta.name}' teacher assistant.") leftSideNavigationDrawerPage.logout() Log.d(STEP_TAG,"Login with user: ${parent.name}, login id: ${parent.loginId}.") @@ -201,7 +200,7 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertDashboardPageDisplayed(parent) - Log.d(STEP_TAG,"Log out with ${parent.name} parent.") + Log.d(STEP_TAG,"Log out with '${parent.name}' parent.") leftSideNavigationDrawerPage.logout() } @@ -261,43 +260,29 @@ class LoginE2ETest : StudentTest() { val enrollmentsService = retrofitClient.create(EnrollmentsApi.EnrollmentsService::class.java) Log.d(PREPARATION_TAG,"Create student, teacher, and a course via API.") - val student = UserApi.createCanvasUser(userService = userService, userDomain = domain) - val teacher = UserApi.createCanvasUser(userService = userService, userDomain = domain) + val student = UserApi.createCanvasUser(userService, domain) + val teacher = UserApi.createCanvasUser(userService, domain) val course = CoursesApi.createCourse(coursesService = coursesService) - Log.d(PREPARATION_TAG,"Enroll ${student.name} student to ${course.name} course.") - enrollUser(course, student, STUDENT_ENROLLMENT, enrollmentsService) + Log.d(PREPARATION_TAG,"Enroll '${student.name}' student to '${course.name}' course.") + EnrollmentsApi.enrollUser(course.id, student.id, STUDENT_ENROLLMENT, enrollmentsService) - Log.d(PREPARATION_TAG,"Enroll ${teacher.name} teacher to ${course.name} course.") - enrollUser(course, teacher, TEACHER_ENROLLMENT, enrollmentsService) + Log.d(PREPARATION_TAG,"Enroll '${teacher.name}' teacher to '${course.name}' course.") + EnrollmentsApi.enrollUser(course.id, teacher.id, TEACHER_ENROLLMENT, enrollmentsService) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") loginWithUser(student) - Log.d(STEP_TAG,"Attempt to sign into our vanity domain, and validate ${student.name} user's role as a Student.") + Log.d(STEP_TAG,"Attempt to sign into our vanity domain, and validate '${student.name}' user's role as a Student.") validateUserAndRole(student, course,"Student" ) Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Log out with ${student.name} student.") + Log.d(STEP_TAG,"Log out with '${student.name}' student.") leftSideNavigationDrawerPage.logout() } - private fun enrollUser( - course: CourseApiModel, - student: CanvasUserApiModel, - enrollmentType: String, - enrollmentsService: EnrollmentsApi.EnrollmentsService - ) { - EnrollmentsApi.enrollUser( - courseId = course.id, - userId = student.id, - enrollmentType = enrollmentType, - enrollmentService = enrollmentsService - ) - } - private fun loginWithUser(user: CanvasUserApiModel, lastSchoolSaved: Boolean = false) { Thread.sleep(5100) //Need to wait > 5 seconds before each login attempt because of new 'too many attempts' login policy on web. @@ -311,7 +296,7 @@ class LoginE2ETest : StudentTest() { loginLandingPage.clickFindMySchoolButton() } - Log.d(STEP_TAG,"Enter domain: ${user.domain}.") + Log.d(STEP_TAG,"Enter domain: '${user.domain}'.") loginFindSchoolPage.enterDomain(user.domain) Log.d(STEP_TAG,"Click on 'Next' button on the Toolbar.") @@ -324,7 +309,7 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on last saved school's button.") loginLandingPage.clickOnLastSavedSchoolButton() - Log.d(STEP_TAG, "Login with ${user.name} user.") + Log.d(STEP_TAG, "Login with '${user.name}' user.") loginSignInPage.loginAs(user) } @@ -333,11 +318,11 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertDashboardPageDisplayed(user) - Log.d(STEP_TAG,"Navigate to 'People' Page of ${course.name} course.") + Log.d(STEP_TAG,"Navigate to 'People' Page of '${course.name}' course.") dashboardPage.selectCourse(course) courseBrowserPage.selectPeople() - Log.d(STEP_TAG,"Assert that ${user.name} user's role is: $role.") + Log.d(STEP_TAG,"Assert that '${user.name}' user's role is: $role.") peopleListPage.assertPersonListed(user, role) } 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 862b6a0c87..cf15715843 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 @@ -28,9 +28,7 @@ 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.GradingType import com.instructure.dataseeding.model.ModuleItemTypes import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days @@ -44,6 +42,7 @@ import org.junit.Test @HiltAndroidTest class ModulesE2ETest: StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -59,51 +58,51 @@ class ModulesE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val assignment1 = createAssignment(course, true, teacher, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val assignment1 = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Seeding another assignment for ${course.name} course.") - val assignment2 = createAssignment(course, true, teacher, 2.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding another assignment for '${course.name}' course.") + val assignment2 = AssignmentsApi.createAssignment(course.id, teacher.token, dueAt = 2.days.fromNow.iso8601, withDescription = true, gradingType = GradingType.POINTS, pointsPossible = 15.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Create a PUBLISHED quiz for ${course.name} course.") - val quiz1 = createQuiz(course, teacher) + Log.d(PREPARATION_TAG,"Create a PUBLISHED quiz for '${course.name}' course.") + val quiz1 = QuizzesApi.createQuiz(course.id, teacher.token, dueAt = 3.days.fromNow.iso8601) - Log.d(PREPARATION_TAG,"Create a page for ${course.name} course.") - val page1 = createCoursePage(course, teacher) + Log.d(PREPARATION_TAG,"Create a page for '${course.name}' course.") + val page1 = PagesApi.createCoursePage(course.id, teacher.token) - Log.d(PREPARATION_TAG,"Create a discussion topic for ${course.name} course.") - val discussionTopic1 = createDiscussion(course, teacher) + Log.d(PREPARATION_TAG,"Create a discussion topic for '${course.name}' course.") + val discussionTopic1 = DiscussionTopicsApi.createDiscussion(course.id, teacher.token) //Modules start out as unpublished. - Log.d(PREPARATION_TAG,"Create a module for ${course.name} course.") - val module1 = createModule(course, teacher) + Log.d(PREPARATION_TAG,"Create a module for '${course.name}' course.") + val module1 = ModulesApi.createModule(course.id, teacher.token) - Log.d(PREPARATION_TAG,"Create another module for ${course.name} course.") - val module2 = createModule(course, teacher) + Log.d(PREPARATION_TAG,"Create another module for '${course.name}' course.") + val module2 = ModulesApi.createModule(course.id, teacher.token) - Log.d(PREPARATION_TAG,"Associate ${assignment1.name} assignment with ${module1.name} module.") - createModuleItem(course.id, module1.id, teacher, assignment1.name, ModuleItemTypes.ASSIGNMENT.stringVal, assignment1.id.toString()) + Log.d(PREPARATION_TAG,"Associate '${assignment1.name}' assignment with '${module1.name}' module.") + ModulesApi.createModuleItem(course.id, teacher.token, module1.id, assignment1.name, ModuleItemTypes.ASSIGNMENT.stringVal, contentId = assignment1.id.toString()) - Log.d(PREPARATION_TAG,"Associate ${quiz1.title} quiz with ${module1.name} module.") - createModuleItem(course.id, module1.id, teacher, quiz1.title, ModuleItemTypes.QUIZ.stringVal, quiz1.id.toString()) + Log.d(PREPARATION_TAG,"Associate '${quiz1.title}' quiz with '${module1.name}' module.") + ModulesApi.createModuleItem(course.id, teacher.token, module1.id, quiz1.title, ModuleItemTypes.QUIZ.stringVal, contentId = quiz1.id.toString()) - Log.d(PREPARATION_TAG,"Associate ${assignment2.name} assignment with ${module2.name} module.") - createModuleItem(course.id, module2.id, teacher, assignment2.name, ModuleItemTypes.ASSIGNMENT.stringVal, assignment2.id.toString()) + Log.d(PREPARATION_TAG,"Associate '${assignment2.name}' assignment with '${module2.name}' module.") + ModulesApi.createModuleItem(course.id, teacher.token, module2.id, assignment2.name, ModuleItemTypes.ASSIGNMENT.stringVal, contentId = assignment2.id.toString()) - Log.d(PREPARATION_TAG,"Associate ${page1.title} page with ${module2.name} module.") - createModuleItem(course.id, module2.id, teacher, page1.title, ModuleItemTypes.PAGE.stringVal, null, page1.url) + Log.d(PREPARATION_TAG,"Associate '${page1.title}' page with '${module2.name}' module.") + ModulesApi.createModuleItem(course.id, teacher.token, module2.id, page1.title, ModuleItemTypes.PAGE.stringVal, pageUrl = page1.url) - Log.d(PREPARATION_TAG,"Associate ${discussionTopic1.title} discussion topic with ${module2.name} module.") - createModuleItem(course.id, module2.id, teacher, discussionTopic1.title, ModuleItemTypes.DISCUSSION.stringVal, discussionTopic1.id.toString()) + Log.d(PREPARATION_TAG,"Associate '${discussionTopic1.title}' discussion topic with '${module2.name}' module.") + ModulesApi.createModuleItem(course.id, teacher.token, module2.id, discussionTopic1.title, ModuleItemTypes.DISCUSSION.stringVal, contentId = discussionTopic1.id.toString()) Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Assert that ${course.name} course is displayed.") + Log.d(STEP_TAG,"Assert that '${course.name}' course is displayed.") dashboardPage.assertDisplaysCourse(course) - Log.d(STEP_TAG,"Select ${course.name} course.") + Log.d(STEP_TAG,"Select '${course.name}' course.") dashboardPage.selectCourse(course) Log.d(STEP_TAG,"Assert that there are no modules displayed yet because there are not published. Assert that the 'Modules' Tab is not displayed as well.") @@ -117,11 +116,11 @@ class ModulesE2ETest: StudentTest() { Log.d(STEP_TAG,"Navigate back to Course Browser Page.") Espresso.pressBack() - Log.d(PREPARATION_TAG,"Publish ${module1.name} module.") - publishModule(course, module1, teacher) + Log.d(PREPARATION_TAG,"Publish '${module1.name}' module.") + ModulesApi.updateModule(course.id, teacher.token, module1.id, published = true) - Log.d(PREPARATION_TAG,"Publish ${module2.name} module.") - publishModule(course, module2, teacher) + Log.d(PREPARATION_TAG,"Publish '${module2.name}' module.") + ModulesApi.updateModule(course.id, teacher.token, module2.id, published = true) Log.d(STEP_TAG,"Refresh the page. Assert that the 'Modules' Tab is displayed.") courseBrowserPage.refresh() @@ -130,13 +129,13 @@ class ModulesE2ETest: StudentTest() { Log.d(STEP_TAG,"Navigate to Modules Page.") courseBrowserPage.selectModules() - Log.d(STEP_TAG,"Assert that ${module1.name} module is displayed with the following items: ${assignment1.name} assignment, ${quiz1.title} quiz.") + Log.d(STEP_TAG,"Assert that '${module1.name}' module is displayed with the following items: '${assignment1.name}' assignment, '${quiz1.title}' quiz.") modulesPage.assertModuleDisplayed(module1) modulesPage.assertModuleItemDisplayed(module1, assignment1.name) modulesPage.assertModuleItemDisplayed(module1, quiz1.title) - Log.d(STEP_TAG,"Assert that ${module2.name} module is displayed with the following items: ${assignment2.name} assignment," + - " ${page1.title} page, ${discussionTopic1.title} discussion topic.") + Log.d(STEP_TAG,"Assert that '${module2.name}' module is displayed with the following items: '${assignment2.name}' assignment," + + " '${page1.title}' page, '${discussionTopic1.title}' discussion topic.") modulesPage.assertModuleDisplayed(module2) modulesPage.assertModuleItemDisplayed(module2, assignment2.name) modulesPage.assertModuleItemDisplayed(module2, page1.title) @@ -150,97 +149,9 @@ class ModulesE2ETest: StudentTest() { 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.") + 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 publishModule( - course: CourseApiModel, - module1: ModuleApiModel, - teacher: CanvasUserApiModel - ) { - ModulesApi.updateModule( - courseId = course.id, - id = module1.id, - published = true, - teacherToken = teacher.token - ) - } - - private fun createModuleItem( - courseId: Long, - moduleId: Long, - teacher: CanvasUserApiModel, - title: String, - moduleItemType: String, - contentId: String?, - pageUrl: String? = null - ) { - ModulesApi.createModuleItem( - courseId = courseId, - moduleId = moduleId, - teacherToken = teacher.token, - title = title, - type = moduleItemType, - contentId = contentId, - pageUrl = pageUrl - ) - } - - private fun createModule( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = ModulesApi.createModule( - courseId = course.id, - teacherToken = teacher.token, - unlockAt = null - ) - - private fun createDiscussion( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = DiscussionTopicsApi.createDiscussion( - courseId = course.id, - token = teacher.token - ) - - private fun createCoursePage( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = PagesApi.createCoursePage( - courseId = course.id, - published = true, - frontPage = false, - token = teacher.token - ) - - private fun createQuiz( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = QuizzesApi.createQuiz( - QuizzesApi.CreateQuizRequest( - courseId = course.id, - withDescription = true, - dueAt = 3.days.fromNow.iso8601, - token = teacher.token, - published = true - ) - ) - - private fun createAssignment( - course: CourseApiModel, - withDescription: Boolean, - teacher: CanvasUserApiModel, - dueAt: String - ) = AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = withDescription, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - teacherToken = teacher.token, - dueAt = dueAt - ) - ) } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/NotificationsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/NotificationsE2ETest.kt index 83723950af..5278f57e4e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/NotificationsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/NotificationsE2ETest.kt @@ -21,16 +21,12 @@ import androidx.test.espresso.NoMatchingViewException import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority -import com.instructure.canvas.espresso.ReleaseExclude import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.QuizzesApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.QuizAnswer import com.instructure.dataseeding.model.QuizQuestion @@ -47,11 +43,11 @@ import java.lang.Thread.sleep @HiltAndroidTest class NotificationsE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit - @ReleaseExclude("The notifications API sometimes is slow and the test is breaking because the notifications aren't show up in time.") @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) @@ -63,10 +59,10 @@ class NotificationsE2ETest : StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seed an assignment for ${course.name} course.") - val testAssignment = createAssignment(course, teacher) + Log.d(PREPARATION_TAG,"Seed an assignment for '${course.name}' course.") + val testAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Seed a quiz for ${course.name} course with some questions.") + Log.d(PREPARATION_TAG,"Seed a quiz for '${course.name}' course with some questions.") val quizQuestions = makeQuizQuestions() Log.d(PREPARATION_TAG,"Create and publish a quiz with the previously seeded questions.") @@ -111,11 +107,11 @@ class NotificationsE2ETest : StudentTest() { run submitAndGradeRepeat@{ repeat(10) { try { - Log.d(PREPARATION_TAG, "Submit ${testAssignment.name} assignment with student: ${student.name}.") - submitAssignment(course, testAssignment, student) + Log.d(PREPARATION_TAG, "Submit '${testAssignment.name}' assignment with student: '${student.name}'.") + SubmissionsApi.submitCourseAssignment(course.id, student.token, testAssignment.id, SubmissionType.ONLINE_TEXT_ENTRY) - Log.d(PREPARATION_TAG, "Grade the submission of ${student.name} student for assignment: ${testAssignment.name}.") - gradeSubmission(teacher, course, testAssignment, student) + Log.d(PREPARATION_TAG, "Grade the submission of '${student.name}' student for assignment: '${testAssignment.name}'.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, testAssignment.id, student.id, postedGrade = "13") Log.d(STEP_TAG, "Refresh the Notifications Page. Assert that there is a notification about the submission grading appearing.") sleep(3000) //Let the submission api do it's job @@ -129,36 +125,6 @@ class NotificationsE2ETest : StudentTest() { } } - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - testAssignment: AssignmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = testAssignment.id, - studentId = student.id, - postedGrade = "13", - excused = false - ) - } - - private fun submitAssignment( - course: CourseApiModel, - testAssignment: AssignmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_TEXT_ENTRY, - courseId = course.id, - assignmentId = testAssignment.id, - studentToken = student.token, - fileIds = emptyList().toMutableList() - ) - } - private fun makeQuizQuestions() = listOf( QuizQuestion( questionText = "What's your favorite color?", @@ -181,21 +147,4 @@ class NotificationsE2ETest : StudentTest() { ) ) ) - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) : AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = GradingType.POINTS, - teacherToken = teacher.token, - pointsPossible = 15.0, - dueAt = 1.days.fromNow.iso8601 - ) - ) - } - } \ No newline at end of file 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 584fa2f705..8903a2c4cb 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 @@ -25,9 +25,6 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.PagesApi -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel -import com.instructure.dataseeding.util.Randomizer import com.instructure.student.ui.pages.WebViewTextCheck import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData @@ -53,16 +50,16 @@ class PagesE2ETest: StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seed an UNPUBLISHED page for '${course.name}' course.") - val pageUnpublished = createCoursePage(course, teacher, published = false, frontPage = false) + val pageUnpublished = PagesApi.createCoursePage(course.id, teacher.token, published = false) 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

") + val pagePublished = PagesApi.createCoursePage(course.id, teacher.token, editingRoles = "teachers,students", body = "

Regular 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

") + val pageNotEditable = PagesApi.createCoursePage(course.id, teacher.token, 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, editingRoles = "public", body = "

Front Page Text

") + val pagePublishedFront = PagesApi.createCoursePage(course.id, teacher.token, frontPage = true, editingRoles = "public", body = "

Front Page Text

") Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) @@ -137,20 +134,4 @@ class PagesE2ETest: StudentTest() { 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( - course: CourseApiModel, - 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 - ) } \ No newline at end of file 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 89709d0222..4a05b67783 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 @@ -51,7 +51,7 @@ class PeopleE2ETest : StudentTest() { tokenLogin(student1) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Navigate to ${course.name} course's People Page.") + Log.d(STEP_TAG,"Navigate to '${course.name}' course's People Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectPeople() @@ -70,7 +70,7 @@ class PeopleE2ETest : StudentTest() { peopleListPage.assertPersonListed(student2) peopleListPage.assertPeopleCount(3) - Log.d(STEP_TAG,"Select ${student2.name} student and assert if we are landing on the Person Details Page.") + Log.d(STEP_TAG,"Select ${student2.name} student and assert if we are landing on the Person Details Page. Assert that the Person Details page's information (user name, role, and picture) are displayed.") peopleListPage.selectPerson(student2) personDetailsPage.assertPageObjects() 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 f6094a40ba..e1073754a1 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 @@ -36,9 +36,6 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.isElementDisplayed import com.instructure.dataseeding.api.QuizzesApi -import com.instructure.dataseeding.api.QuizzesApi.createAndPublishQuiz -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.QuizAnswer import com.instructure.dataseeding.model.QuizQuestion import com.instructure.student.R @@ -57,7 +54,7 @@ class QuizzesE2ETest: StudentTest() { override fun enableAndConfigureAccessibilityChecks() = Unit - // Fairly basic test of webview-based quizzes. Seeds/takes a quiz with two multiple-choice + // Fairly basic test of web view-based quizzes. Seeds/takes a quiz with two multiple-choice // questions. // // STUBBING THIS OUT. Usually passes locally, but I can't get a simple webClick() to work on FTL. @@ -75,13 +72,13 @@ class QuizzesE2ETest: StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seed a quiz for ${course.name} course.") - val quizUnpublished = createQuiz(course, teacher, withDescription = true, published = false) + val quizUnpublished = QuizzesApi.createQuiz(course.id, teacher.token, published = false) Log.d(PREPARATION_TAG,"Seed another quiz for ${course.name} with some questions.") val quizQuestions = makeQuizQuestions() Log.d(PREPARATION_TAG,"Publish the previously seeded quiz.") - val quizPublished = createAndPublishQuiz(course.id, teacher.token, quizQuestions) + val quizPublished = QuizzesApi.createAndPublishQuiz(course.id, teacher.token, quizQuestions) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -181,6 +178,7 @@ class QuizzesE2ETest: StudentTest() { Log.d(STEP_TAG,"Select ${quizPublished.title} quiz.") quizListPage.selectQuiz(quizPublished) + sleep(5000) Log.d(STEP_TAG,"Assert (on web) that the ${quizPublished.title} quiz now has a history.") onWebView(withId(R.id.contentWebView)) .withElement(findElement(Locator.ID, "quiz-submission-version-table")) @@ -204,20 +202,6 @@ class QuizzesE2ETest: StudentTest() { } - private fun createQuiz( - course: CourseApiModel, - teacher: CanvasUserApiModel, - withDescription: Boolean, - published: Boolean, - ) = QuizzesApi.createQuiz( - QuizzesApi.CreateQuizRequest( - courseId = course.id, - withDescription = withDescription, - published = published, - token = teacher.token - ) - ) - private fun makeQuizQuestions() = listOf( QuizQuestion( questionText = "What's your favorite color?", diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SettingsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SettingsE2ETest.kt index babe1f110d..ca431ea2ab 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SettingsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SettingsE2ETest.kt @@ -66,10 +66,10 @@ class SettingsE2ETest : StudentTest() { profileSettingsPage.assertPageObjects() val newUserName = "John Doe" - Log.d(STEP_TAG, "Edit username to: $newUserName. Click on 'Save' button.") + Log.d(STEP_TAG, "Edit username to: '$newUserName'. Click on 'Save' button.") profileSettingsPage.changeUserNameTo(newUserName) - Log.d(STEP_TAG, "Navigate back to Dashboard Page. Assert that the username has been changed to $newUserName.") + Log.d(STEP_TAG, "Navigate back to Dashboard Page. Assert that the username has been changed to '$newUserName'.") ViewUtils.pressBackButton(2) leftSideNavigationDrawerPage.assertUserLoggedIn(newUserName) 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 8df03a41d9..0d611d4474 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 @@ -25,9 +25,6 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import com.instructure.canvas.espresso.E2E import com.instructure.dataseeding.api.AssignmentsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days @@ -61,11 +58,11 @@ class ShareExtensionE2ETest: StudentTest() { val course = data.coursesList[0] val teacher = data.teachersList[0] - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val testAssignmentOne = createAssignment(course, teacher, 1.days.fromNow.iso8601, 15.0) + Log.d(PREPARATION_TAG,"Seeding 'File upload' assignment for ${course.name} course.") + val testAssignmentOne = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD)) - Log.d(PREPARATION_TAG,"Seeding another 'Text Entry' assignment for ${course.name} course.") - createAssignment(course, teacher, 1.days.fromNow.iso8601, 30.0) + Log.d(PREPARATION_TAG,"Seeding another 'File upload' assignment for ${course.name} course.") + AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 30.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD)) Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) @@ -205,24 +202,6 @@ class ShareExtensionE2ETest: StudentTest() { fileListPage.assertItemDisplayed(jpgTestFileName) } - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel, - dueAt: String, - pointsPossible: Double - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), - gradingType = GradingType.POINTS, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = dueAt - ) - ) - } - 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/SyllabusE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SyllabusE2ETest.kt index 8db435bf9b..245e3c135d 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SyllabusE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SyllabusE2ETest.kt @@ -24,8 +24,6 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.QuizzesApi -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow @@ -55,9 +53,9 @@ class SyllabusE2ETest: StudentTest() { 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.") + Log.d(STEP_TAG,"Wait for the Dashboard Page to be rendered. Select '${course.name}' course.") + dashboardPage.waitForRender() dashboardPage.selectCourse(course) Log.d(STEP_TAG,"Navigate to Syllabus Page. Assert that the syllabus body string is displayed, and there are no tabs yet.") @@ -65,42 +63,16 @@ class SyllabusE2ETest: StudentTest() { syllabusPage.assertNoTabs() syllabusPage.assertSyllabusBody("this is the syllabus body") - Log.d(PREPARATION_TAG,"Seed an assignment for ${course.name} course.") - val assignment = createAssignment(course, teacher) + Log.d(PREPARATION_TAG,"Seed an assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, submissionTypes = listOf(SubmissionType.ON_PAPER), withDescription = true, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601) - Log.d(PREPARATION_TAG,"Seed a quiz for ${course.name} course.") - val quiz = createQuiz(course, teacher) + Log.d(PREPARATION_TAG,"Seed a quiz for '${course.name}' course.") + val quiz = QuizzesApi.createQuiz(course.id, teacher.token, dueAt = 2.days.fromNow.iso8601) - Log.d(STEP_TAG,"Refresh the page. Navigate to 'Summary' tab. Assert that all of the items, so ${assignment.name} assignment and ${quiz.title} quiz are displayed.") + Log.d(STEP_TAG,"Refresh the page. Navigate to 'Summary' tab. Assert that all of the items, so '${assignment.name}' assignment and '${quiz.title}' quiz are displayed.") syllabusPage.refresh() syllabusPage.selectSummaryTab() syllabusPage.assertItemDisplayed(assignment.name) syllabusPage.assertItemDisplayed(quiz.title) } - - private fun createQuiz( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = QuizzesApi.createQuiz( - QuizzesApi.CreateQuizRequest( - courseId = course.id, - withDescription = true, - published = true, - token = teacher.token, - dueAt = 2.days.fromNow.iso8601 - ) - ) - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - teacherToken = teacher.token, - submissionTypes = listOf(SubmissionType.ON_PAPER), - dueAt = 1.days.fromNow.iso8601, - withDescription = true - ) - ) } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt index 32b3540313..08e00b02e0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt @@ -3,23 +3,20 @@ package com.instructure.student.ui.e2e import android.util.Log import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.QuizzesApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GradingType 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.espresso.retry -import com.instructure.canvas.espresso.FeatureCategory -import com.instructure.canvas.espresso.Priority -import com.instructure.canvas.espresso.TestCategory -import com.instructure.canvas.espresso.TestMetaData +import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedAssignments import com.instructure.student.ui.utils.seedData @@ -46,10 +43,10 @@ class TodoE2ETest: StudentTest() { val course = data.coursesList[0] val favoriteCourse = data.coursesList[1] - Log.d(PREPARATION_TAG,"Seed an assignment for ${course.name} course with tomorrow due date.") - val testAssignment = createAssignment(course, teacher) + Log.d(PREPARATION_TAG,"Seed an assignment for '${course.name}' course with tomorrow due date.") + val testAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Seed another assignment for ${course.name} course with 7 days from now due date.") + Log.d(PREPARATION_TAG,"Seed another assignment for '${course.name}' course with 7 days from now due date.") val seededAssignments2 = seedAssignments( courseId = course.id, teacherToken = teacher.token, @@ -58,38 +55,35 @@ class TodoE2ETest: StudentTest() { val borderDateAssignment = seededAssignments2[0] //We show items in the to do section which are within 7 days. - Log.d(PREPARATION_TAG,"Seed a quiz for ${course.name} course with tomorrow due date.") - val quiz = createQuiz(course, teacher, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seed a quiz for '${course.name}' course with tomorrow due date.") + val quiz = QuizzesApi.createQuiz(course.id, teacher.token, dueAt = 1.days.fromNow.iso8601) - Log.d(PREPARATION_TAG,"Seed another quiz for ${course.name} course with 8 days from now due date..") - val tooFarAwayQuiz = createQuiz(course, teacher, 8.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seed another quiz for '${course.name}' course with 8 days from now due date..") + val tooFarAwayQuiz = QuizzesApi.createQuiz(course.id, teacher.token, dueAt = 8.days.fromNow.iso8601) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) - dashboardPage.waitForRender() - Log.d(STEP_TAG,"Navigate to 'To Do' Page via bottom-menu.") + Log.d(STEP_TAG,"Wait for the Dashboard Page to be rendered. Navigate to 'To Do' Page via bottom-menu.") + dashboardPage.waitForRender() dashboardPage.clickTodoTab() - Log.d(STEP_TAG,"Assert that ${testAssignment.name} assignment is displayed and ${borderDateAssignment.name} is displayed because it's 7 days away from now..") - Log.d(STEP_TAG,"Assert that ${quiz.title} quiz is displayed and ${tooFarAwayQuiz.title} quiz is not displayed because it's end date is more than a week away..") - retry(times = 5, delay = 3000, catchBlock = { refresh() } ) { + Log.d(STEP_TAG,"Assert that '${testAssignment.name}' assignment is displayed and '${borderDateAssignment.name}' assignment is displayed because it's 7 days away from now.") + Log.d(STEP_TAG,"Assert that '${quiz.title}' quiz is displayed and '${tooFarAwayQuiz.title}' quiz is not displayed because it's end date is more than a week away.") + retryWithIncreasingDelay(times = 10, maxDelay = 3000, catchBlock = { refresh() } ) { todoPage.assertAssignmentDisplayed(testAssignment) todoPage.assertAssignmentDisplayed(borderDateAssignment) todoPage.assertQuizDisplayed(quiz) todoPage.assertQuizNotDisplayed(tooFarAwayQuiz) } - Log.d(PREPARATION_TAG,"Submit ${testAssignment.name} assignment for ${student.name} student.") - SubmissionsApi.seedAssignmentSubmission(SubmissionsApi.SubmissionSeedRequest( - assignmentId = testAssignment.id, - courseId = course.id, - studentToken = student.token, + Log.d(PREPARATION_TAG,"Submit' ${testAssignment.name}' assignment for ${student.name} student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, testAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY )) - )) + ) Log.d(STEP_TAG, "Refresh the 'To Do' Page.") refresh() @@ -115,10 +109,10 @@ class TodoE2ETest: StudentTest() { todoPage.assertAssignmentNotDisplayed(testAssignment) todoPage.assertQuizNotDisplayed(tooFarAwayQuiz) - Log.d(PREPARATION_TAG,"Seed an assignment for ${favoriteCourse.name} course with tomorrow due date.") - val favoriteCourseAssignment = createAssignment(favoriteCourse, teacher) + Log.d(PREPARATION_TAG,"Seed an assignment for '${favoriteCourse.name}' course with tomorrow due date.") + val favoriteCourseAssignment = AssignmentsApi.createAssignment(favoriteCourse.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(STEP_TAG, "Navigate back to the Dashboard Page. Open ${favoriteCourse.name} course. Mark it as favorite.") + Log.d(STEP_TAG, "Navigate back to the Dashboard Page. Open '${favoriteCourse.name}' course. Mark it as favorite.") Espresso.pressBack() dashboardPage.openAllCoursesPage() allCoursesPage.favoriteCourse(favoriteCourse.name) @@ -141,34 +135,4 @@ class TodoE2ETest: StudentTest() { todoPage.assertQuizNotDisplayed(quiz) todoPage.assertQuizNotDisplayed(tooFarAwayQuiz) } - - private fun createQuiz( - course: CourseApiModel, - teacher: CanvasUserApiModel, - dueAt: String - ) = QuizzesApi.createQuiz( - QuizzesApi.CreateQuizRequest( - courseId = course.id, - withDescription = true, - published = true, - token = teacher.token, - dueAt = dueAt - ) - ) - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = GradingType.POINTS, - teacherToken = teacher.token, - pointsPossible = 15.0, - dueAt = 1.days.fromNow.iso8601 - ) - ) - } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/GradesElementaryE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/GradesElementaryE2ETest.kt index 088b6e0858..6714978284 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/GradesElementaryE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/GradesElementaryE2ETest.kt @@ -21,16 +21,17 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData -import com.instructure.canvasapi2.utils.toApiString import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.GradingPeriodsApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.GradingType 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.espresso.page.getStringFromResource import com.instructure.student.R import com.instructure.student.ui.pages.ElementaryDashboardPage @@ -39,17 +40,17 @@ import com.instructure.student.ui.utils.seedDataForK5 import com.instructure.student.ui.utils.tokenLoginElementary import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test -import java.util.* @HiltAndroidTest class GradesElementaryE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @E2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.E2E, SecondaryFeatureCategory.K5_GRADES) fun gradesE2ETest() { Log.d(PREPARATION_TAG,"Seeding data for K5 sub-account.") @@ -65,20 +66,19 @@ class GradesElementaryE2ETest : StudentTest() { val student = data.studentsList[0] val teacher = data.teachersList[0] val nonHomeroomCourses = data.coursesList.filter { !it.homeroomCourse } - val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val testGradingPeriodListApiModel = GradingPeriodsApi.getGradingPeriodsOfCourse(nonHomeroomCourses[0].id) - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${nonHomeroomCourses[1].name} course.") - val testAssignment = createAssignment(nonHomeroomCourses[1].id, teacher, calendar, GradingType.PERCENT, 100.0) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${nonHomeroomCourses[1].name}' course.") + val testAssignment = AssignmentsApi.createAssignment(nonHomeroomCourses[1].id, teacher.token, gradingType = GradingType.PERCENT, pointsPossible = 100.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Seeding another 'Text Entry' assignment for ${nonHomeroomCourses[0].name} course.") - val testAssignment2 = createAssignment(nonHomeroomCourses[0].id, teacher, calendar, GradingType.LETTER_GRADE, 100.0) + Log.d(PREPARATION_TAG,"Seeding another 'Text Entry' assignment for '${nonHomeroomCourses[0].name}' course.") + val testAssignment2 = AssignmentsApi.createAssignment(nonHomeroomCourses[0].id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 100.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${nonHomeroomCourses[1].name} assignment.") - gradeSubmission(teacher,nonHomeroomCourses[1].id, student, testAssignment.id, "9") + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${nonHomeroomCourses[1].name}' assignment.") + SubmissionsApi.gradeSubmission(teacher.token, nonHomeroomCourses[1].id, testAssignment.id, student.id, postedGrade = "9") - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${nonHomeroomCourses[0].name} assignment.") - gradeSubmission(teacher, nonHomeroomCourses[0].id, student, testAssignment2.id, "A-") + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${nonHomeroomCourses[0].name}' assignment.") + SubmissionsApi.gradeSubmission(teacher.token, nonHomeroomCourses[0].id, testAssignment2.id, student.id, postedGrade = "A-") Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLoginElementary(student) @@ -94,16 +94,17 @@ class GradesElementaryE2ETest : StudentTest() { gradesPage.assertCourseShownWithGrades(nonHomeroomCourses[1].name, "9%") gradesPage.assertCourseShownWithGrades(nonHomeroomCourses[2].name, "Not Graded") - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${testAssignment2.name} assignment.") - gradeSubmission(teacher,nonHomeroomCourses[0].id, student, testAssignment2.id, "C-") + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${testAssignment2.name}' assignment.") + SubmissionsApi.gradeSubmission(teacher.token, nonHomeroomCourses[0].id, testAssignment2.id, student.id, postedGrade = "C-") Thread.sleep(5000) //This time is needed here to let the SubMissionApi does it's job. - Log.d(STEP_TAG, "Refresh Grades Elementary Page. Assert that the previously graded, ${testAssignment2.name} assignment's grade has been changed, but only that one.") + Log.d(STEP_TAG, "Refresh Grades Elementary Page. Assert that the previously graded, '${testAssignment2.name}' assignment's grade has been changed, but only that one.") gradesPage.refresh() Thread.sleep(5000) //We need to wait here because sometimes if we refresh the page fastly, the old grade will be seen. gradesPage.assertCourseShownWithGrades(nonHomeroomCourses[0].name, "73%") gradesPage.assertCourseShownWithGrades(nonHomeroomCourses[1].name, "9%") + gradesPage.assertCourseShownWithGrades(nonHomeroomCourses[2].name, "Not Graded") Log.d(STEP_TAG, "Change 'Current Grading Period' to '${testGradingPeriodListApiModel.gradingPeriods[0].title}'.") gradesPage.assertSelectedGradingPeriod(gradesPage.getStringFromResource(R.string.currentGradingPeriod)) @@ -111,50 +112,14 @@ class GradesElementaryE2ETest : StudentTest() { gradesPage.clickGradingPeriodSelector() gradesPage.selectGradingPeriod(testGradingPeriodListApiModel.gradingPeriods[0].title) - Log.d(STEP_TAG, "Checking if a course's grades page is displayed after clicking on a course row on elementary grades page. Assert that we have left the grades elementary page. We are asserting this because in beta environment, subject page's not always available for k5 user.") + Log.d(STEP_TAG, "Checking if a course's grades page is displayed after clicking on a course row on elementary grades page." + + "Assert that we have left the grades elementary page. We are asserting this because in beta environment, subject page's not always available for k5 user.") gradesPage.clickGradeRow(nonHomeroomCourses[0].name) gradesPage.assertCourseNotDisplayed(nonHomeroomCourses[0].name) Log.d(STEP_TAG, "Navigate back to Grades Elementary Page and assert it's displayed.") Espresso.pressBack() gradesPage.assertPageObjects() - - } - - private fun createAssignment( - courseId: Long, - teacher: CanvasUserApiModel, - calendar: Calendar, - gradingType: GradingType, - pointsPossible: Double - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = gradingType, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = calendar.time.toApiString() - ) - ) - } - - private fun gradeSubmission( - teacher: CanvasUserApiModel, - courseId: Long, - student: CanvasUserApiModel, - assignmentId: Long, - postedGrade: String - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = courseId, - assignmentId = assignmentId, - studentId = student.id, - postedGrade = postedGrade, - excused = false - ) } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/HomeroomE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/HomeroomE2ETest.kt index 6c6703011d..b9d1d7d138 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/HomeroomE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/HomeroomE2ETest.kt @@ -21,11 +21,10 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.AssignmentsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.ago @@ -44,13 +43,14 @@ import org.threeten.bp.format.DateTimeFormatter @HiltAndroidTest class HomeroomE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @E2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.E2E, SecondaryFeatureCategory.HOMEROOM) fun homeroomE2ETest() { Log.d(PREPARATION_TAG,"Seeding data for K5 sub-account.") @@ -68,11 +68,11 @@ class HomeroomE2ETest : StudentTest() { val homeroomAnnouncement = data.announcementsList[0] val nonHomeroomCourses = data.coursesList.filter { !it.homeroomCourse } - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${nonHomeroomCourses[2].name} course.") - val testAssignment = createAssignment(nonHomeroomCourses[2].id, teacher, GradingType.LETTER_GRADE, 100.0, OffsetDateTime.now().plusHours(1).format(DateTimeFormatter.ISO_DATE_TIME)) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${nonHomeroomCourses[2].name}' course.") + val testAssignment = AssignmentsApi.createAssignment(nonHomeroomCourses[2].id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 100.0, dueAt = OffsetDateTime.now().plusHours(1).format(DateTimeFormatter.ISO_DATE_TIME), submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Seeding 'Text Entry' MISSING assignment for ${nonHomeroomCourses[2].name} course.") - val testAssignmentMissing = createAssignment(nonHomeroomCourses[2].id, teacher, GradingType.PERCENT, 100.0, 3.days.ago.iso8601) + val testAssignmentMissing = AssignmentsApi.createAssignment(nonHomeroomCourses[2].id, teacher.token, gradingType = GradingType.PERCENT, pointsPossible = 100.0, dueAt = 3.days.ago.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLoginElementary(student) @@ -81,10 +81,10 @@ class HomeroomE2ETest : StudentTest() { Log.d(STEP_TAG, "Navigate to K5 Important Dates Page and assert it is loaded.") elementaryDashboardPage.selectTab(ElementaryDashboardPage.ElementaryTabType.HOMEROOM) - Log.d(STEP_TAG, "Assert that there is a welcome text with the student's shortname (${student.shortName}).") + Log.d(STEP_TAG, "Assert that there is a welcome text with the student's shortname: '${student.shortName}'.") homeroomPage.assertWelcomeText(student.shortName) - Log.d(STEP_TAG, "Assert that the ${homeroomAnnouncement.title} announcement (which belongs to ${homeroomCourse.name} homeroom course) is displayed.") + Log.d(STEP_TAG, "Assert that the '${homeroomAnnouncement.title}' announcement (which belongs to '${homeroomCourse.name}' homeroom course) is displayed.") homeroomPage.assertAnnouncementDisplayed(homeroomCourse.name, homeroomAnnouncement.title, homeroomAnnouncement.message) Log.d(STEP_TAG, "Assert that under the 'My Subject' section there are 3 items.") @@ -92,7 +92,7 @@ class HomeroomE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'View Previous Announcements'." + "Assert that the Announcement List Page is displayed" + - "and the ${homeroomAnnouncement.title} announcement is displayed as well within the announcement list..") + "and the '${homeroomAnnouncement.title}' announcement is displayed as well within the announcement list..") homeroomPage.clickOnViewPreviousAnnouncements() announcementListPage.assertToolbarTitle() announcementListPage.assertAnnouncementTitleVisible(homeroomAnnouncement.title) @@ -103,7 +103,7 @@ class HomeroomE2ETest : StudentTest() { elementaryDashboardPage.waitForRender() for (i in 0 until nonHomeroomCourses.size - 1) { - Log.d(STEP_TAG, "Assert that the ${nonHomeroomCourses[i].name} course is displayed with the announcements which belongs to it.") + Log.d(STEP_TAG, "Assert that the '${nonHomeroomCourses[i].name}' course is displayed with the announcements which belongs to it.") homeroomPage.assertCourseDisplayed( nonHomeroomCourses[i].name, homeroomPage.getStringFromResource(R.string.nothingDueToday), @@ -115,50 +115,30 @@ class HomeroomE2ETest : StudentTest() { homeroomPage.assertPageObjects() homeroomPage.assertToDoText("1 due today | 1 missing") - Log.d(STEP_TAG, "Open ${nonHomeroomCourses[0].name} course." + + Log.d(STEP_TAG, "Open '${nonHomeroomCourses[0].name}' course." + "Assert that the Course Details Page is displayed and the title is '${nonHomeroomCourses[0].name}' (the course's name).") homeroomPage.openCourse(nonHomeroomCourses[0].name) elementaryCoursePage.assertPageObjects() elementaryCoursePage.assertTitleCorrect(nonHomeroomCourses[0].name) - Log.d(STEP_TAG, "Navigate back to Homeroom Page. Open ${data.announcementsList[1].title} announcement by clicking on it.") + Log.d(STEP_TAG, "Navigate back to Homeroom Page. Open '${data.announcementsList[1].title}' announcement by clicking on it.") Espresso.pressBack() homeroomPage.assertPageObjects() homeroomPage.openCourseAnnouncement(data.announcementsList[1].title) - Log.d(STEP_TAG, "Assert that the ${data.announcementsList[1].title} announcement's details page is displayed.") + Log.d(STEP_TAG, "Assert that the '${data.announcementsList[1].title}' announcement's details page is displayed.") discussionDetailsPage.assertTitleText(data.announcementsList[1].title) - Log.d(STEP_TAG, "Navigate back to Homeroom Page. Open the Assignment List Page of ${nonHomeroomCourses[2].name} course.") + Log.d(STEP_TAG, "Navigate back to Homeroom Page. Open the Assignment List Page of '${nonHomeroomCourses[2].name}' course.") Espresso.pressBack() homeroomPage.assertPageObjects() homeroomPage.openAssignments("1 due today | 1 missing") - Log.d(STEP_TAG, "Assert that the Assignment list page of ${nonHomeroomCourses[2].name} course is loaded well" + - "and the corresponding assignments (Not missing: ${testAssignment.name}, missing: ${testAssignmentMissing.name}) are displayed.") + Log.d(STEP_TAG, "Assert that the Assignment list page of '${nonHomeroomCourses[2].name}' course is loaded well" + + "and the corresponding assignments (Not missing: '${testAssignment.name}', missing: '${testAssignmentMissing.name}') are displayed.") assignmentListPage.assertPageObjects() assignmentListPage.assertHasAssignment(testAssignment) assignmentListPage.assertHasAssignment(testAssignmentMissing) } - - - private fun createAssignment( - courseId: Long, - teacher: CanvasUserApiModel, - gradingType: GradingType, - pointsPossible: Double, - dueAt: String - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = gradingType, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = dueAt - ) - ) - } } 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 d569f6c99b..3628506f0b 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 @@ -21,14 +21,12 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvasapi2.utils.toDate import com.instructure.dataseeding.api.AssignmentsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.GradingType -import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 @@ -43,13 +41,14 @@ import java.util.* @HiltAndroidTest class ImportantDatesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @E2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.E2E, SecondaryFeatureCategory.IMPORTANT_DATES) fun importantDatesE2ETest() { Log.d(PREPARATION_TAG,"Seeding data for K5 sub-account.") @@ -67,17 +66,17 @@ class ImportantDatesE2ETest : StudentTest() { val elementaryCourse3 = data.coursesList[2] val elementaryCourse4 = data.coursesList[3] - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for ${elementaryCourse1.name} course.") - val testAssignment1 = createAssignment(elementaryCourse1.id,teacher, GradingType.POINTS, 100.0, 3.days.fromNow.iso8601, importantDate = true) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for '${elementaryCourse1.name}' course.") + val testAssignment1 = AssignmentsApi.createAssignment(elementaryCourse1.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 100.0, dueAt = 3.days.fromNow.iso8601, importantDate = true) - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for ${elementaryCourse2.name} course.") - val testAssignment2 = createAssignment(elementaryCourse2.id,teacher, GradingType.POINTS, 100.0, 4.days.fromNow.iso8601, importantDate = true) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for '${elementaryCourse2.name}' course.") + val testAssignment2 = AssignmentsApi.createAssignment(elementaryCourse2.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 100.0, dueAt = 4.days.fromNow.iso8601, importantDate = true) - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for ${elementaryCourse3.name} course.") - val testAssignment3 = createAssignment(elementaryCourse3.id,teacher, GradingType.POINTS, 100.0, 4.days.fromNow.iso8601, importantDate = true) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for '${elementaryCourse3.name}' course.") + val testAssignment3 = AssignmentsApi.createAssignment(elementaryCourse3.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 100.0, dueAt = 4.days.fromNow.iso8601, importantDate = true) - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' NOT IMPORTANT assignment for ${elementaryCourse4.name} course.") - val testNotImportantAssignment = createAssignment(elementaryCourse4.id,teacher, GradingType.POINTS, 100.0, 4.days.fromNow.iso8601, importantDate = false) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' NOT IMPORTANT assignment for '${elementaryCourse4.name}' course.") + val testNotImportantAssignment = AssignmentsApi.createAssignment(elementaryCourse4.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 100.0, dueAt = 4.days.fromNow.iso8601, importantDate = false) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLoginElementary(student) @@ -87,7 +86,7 @@ class ImportantDatesE2ETest : StudentTest() { elementaryDashboardPage.selectTab(ElementaryDashboardPage.ElementaryTabType.IMPORTANT_DATES) importantDatesPage.assertPageObjects() - Log.d(STEP_TAG, "Assert that the important date assignments are displayed and the 'not' important (${testNotImportantAssignment.name}) is not displayed.") + Log.d(STEP_TAG, "Assert that the important date assignments are displayed and the 'not' important one, '${testNotImportantAssignment.name}' is not displayed.") importantDatesPage.assertItemDisplayed(testAssignment1.name) importantDatesPage.assertItemDisplayed(testAssignment2.name) importantDatesPage.assertItemDisplayed(testAssignment3.name) @@ -107,7 +106,7 @@ class ImportantDatesE2ETest : StudentTest() { importantDatesPage.assertPageObjects() Log.d(STEP_TAG, "Refresh the Important Dates page. Assert that the corresponding items" + - "(all the assignments, except ${testNotImportantAssignment.name} named assignment) and their labels are still displayed after the refresh.") + "(all the assignments, except '${testNotImportantAssignment.name}' named assignment) and their labels are still displayed after the refresh.") importantDatesPage.pullToRefresh() importantDatesPage.assertItemDisplayed(testAssignment1.name) importantDatesPage.assertItemDisplayed(testAssignment2.name) @@ -118,32 +117,10 @@ class ImportantDatesE2ETest : StudentTest() { importantDatesPage.assertRecyclerViewItemCount(3) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment1.dueAt.toDate())) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment2.dueAt.toDate())) - } private fun generateDayString(date: Date?): String { return SimpleDateFormat("EEEE, MMMM dd", Locale.getDefault()).format(date) } - - private fun createAssignment( - courseId: Long, - teacher: CanvasUserApiModel, - gradingType: GradingType, - pointsPossible: Double, - dueAt: String, - importantDate: Boolean - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = gradingType, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = dueAt, - importantDate = importantDate - ) - ) - } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ResourcesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ResourcesE2ETest.kt index dfdf5c229d..0337f8d4c5 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ResourcesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ResourcesE2ETest.kt @@ -21,6 +21,7 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.model.CanvasUserApiModel @@ -33,13 +34,14 @@ import org.junit.Test @HiltAndroidTest class ResourcesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @E2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.E2E, SecondaryFeatureCategory.RESOURCES) fun resourcesE2ETest() { Log.d(PREPARATION_TAG,"Seeding data for K5 sub-account.") @@ -65,9 +67,9 @@ class ResourcesE2ETest : StudentTest() { resourcesPage.assertPageObjects() Log.d(STEP_TAG, "Assert that the important links, LTI tools and contacts are displayed.") - assertElementaryResourcesPageInformations(teacher) + assertElementaryResourcesPageInformation(teacher) - Log.d(STEP_TAG, "Click on the compose message icon next to a contact (${teacher.name}), and verify if the new message page is displayed.") + Log.d(STEP_TAG, "Click on the compose message icon next to a contact ('${teacher.name}' teacher), and verify if the new message page is displayed.") resourcesPage.openComposeMessage(teacher.shortName) assertNewMessagePageDisplayed() @@ -76,7 +78,7 @@ class ResourcesE2ETest : StudentTest() { resourcesPage.assertPageObjects() Log.d(STEP_TAG, "Assert that the important links, LTI tools and contacts are still displayed correctly, after the navigation.") - assertElementaryResourcesPageInformations(teacher) + assertElementaryResourcesPageInformation(teacher) Log.d(STEP_TAG, "Open an LTI tool (Google Drive), and verify if all the NON-homeroom courses are displayed within the 'Choose a Course' list.") resourcesPage.openLtiApp("Google Drive") @@ -85,9 +87,7 @@ class ResourcesE2ETest : StudentTest() { } } - private fun assertElementaryResourcesPageInformations( - teacher: CanvasUserApiModel - ) { + private fun assertElementaryResourcesPageInformation(teacher: CanvasUserApiModel) { resourcesPage.assertImportantLinksHeaderDisplayed() resourcesPage.assertStudentApplicationsHeaderDisplayed() resourcesPage.assertStaffInfoHeaderDisplayed() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt index a178f13783..243bcc03c1 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt @@ -21,12 +21,13 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.ReleaseExclude +import com.instructure.canvas.espresso.SecondaryFeatureCategory +import com.instructure.canvas.espresso.Stub import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvasapi2.utils.toApiString import com.instructure.dataseeding.api.AssignmentsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType import com.instructure.espresso.page.getStringFromResource @@ -50,11 +51,13 @@ class ScheduleE2ETest : StudentTest() { override fun enableAndConfigureAccessibilityChecks() = Unit @Rule + @JvmField var globalTimeout: Timeout = Timeout.millis(600000) // //TODO: workaround for that sometimes this test is running infinite time because of scrollToElement does not find an element. + @Stub @E2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.E2E, SecondaryFeatureCategory.SCHEDULE) fun scheduleE2ETest() { Log.d(PREPARATION_TAG,"Seeding data for K5 sub-account.") @@ -74,13 +77,13 @@ class ScheduleE2ETest : StudentTest() { val twoWeeksAfterCalendar = getCustomDateCalendar(15) Log.d(PREPARATION_TAG,"Seeding 'Text Entry' MISSING assignment for ${nonHomeroomCourses[2].name} course.") - val testMissingAssignment = createAssignment(nonHomeroomCourses[2].id, teacher, currentDateCalendar, GradingType.LETTER_GRADE,100.0) + val testMissingAssignment = AssignmentsApi.createAssignment(nonHomeroomCourses[2].id, teacher.token, dueAt = currentDateCalendar.time.toApiString(), gradingType = GradingType.LETTER_GRADE, pointsPossible = 100.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Seeding 'Text Entry' Two weeks before end date assignment for ${nonHomeroomCourses[1].name} course.") - val testTwoWeeksBeforeAssignment = createAssignment(nonHomeroomCourses[1].id, teacher, twoWeeksBeforeCalendar, GradingType.PERCENT,100.0) + val testTwoWeeksBeforeAssignment = AssignmentsApi.createAssignment(nonHomeroomCourses[1].id, teacher.token, dueAt = twoWeeksBeforeCalendar.time.toApiString(), gradingType = GradingType.PERCENT, pointsPossible = 100.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Seeding 'Text Entry' Two weeks after end date assignment for ${nonHomeroomCourses[0].name} course.") - val testTwoWeeksAfterAssignment = createAssignment(nonHomeroomCourses[0].id, teacher, twoWeeksAfterCalendar, GradingType.POINTS,25.0) + val testTwoWeeksAfterAssignment = AssignmentsApi.createAssignment(nonHomeroomCourses[0].id, teacher.token, dueAt = twoWeeksAfterCalendar.time.toApiString(), gradingType = GradingType.POINTS, pointsPossible = 25.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLoginElementary(student) @@ -214,24 +217,5 @@ class ScheduleE2ETest : StudentTest() { return cal } - private fun createAssignment( - courseId: Long, - teacher: CanvasUserApiModel, - calendar: Calendar, - gradingType: GradingType, - pointsPossible: Double - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = gradingType, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = calendar.time.toApiString() - ) - ) - } - } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt index d4f6766120..7f324a2cdc 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt @@ -18,14 +18,14 @@ package com.instructure.student.ui.e2e.offline import android.util.Log import androidx.test.espresso.Espresso -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice import com.google.android.material.checkbox.MaterialCheckBox import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData import com.instructure.student.ui.utils.tokenLogin @@ -35,13 +35,14 @@ import org.junit.Test @HiltAndroidTest class ManageOfflineContentE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.OFFLINE_CONTENT, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.OFFLINE_CONTENT, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testManageOfflineContentE2ETest() { Log.d(PREPARATION_TAG,"Seeding data.") @@ -50,10 +51,7 @@ class ManageOfflineContentE2ETest : StudentTest() { val course1 = data.coursesList[0] val course2 = data.coursesList[1] - Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -197,20 +195,13 @@ class ManageOfflineContentE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on the 'Sync' button and confirm sync.") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course2.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt index b45e9395bb..cb99b21f54 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt @@ -19,8 +19,6 @@ package com.instructure.student.ui.e2e.offline import android.util.Log import androidx.test.espresso.Espresso import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice import com.google.android.material.checkbox.MaterialCheckBox import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E @@ -28,16 +26,17 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline 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.After import org.junit.Test -import java.lang.Thread.sleep @HiltAndroidTest class OfflineAllCoursesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -54,10 +53,7 @@ class OfflineAllCoursesE2ETest : StudentTest() { val course2 = data.coursesList[1] val course3 = data.coursesList[2] - Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -83,23 +79,15 @@ class OfflineAllCoursesE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState(course2.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Assert that the offline sync icon is displayed on the synced (and favorited) course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course1.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered, and assert that '${course1.name}' is the only course which is displayed on the offline mode Dashboard Page.") - dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course1) dashboardPage.assertCourseNotDisplayed(course2) dashboardPage.assertCourseNotDisplayed(course3) @@ -136,7 +124,6 @@ class OfflineAllCoursesE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on '${course1.name}' course and assert if it will navigate the user to the CourseBrowser Page.") allCoursesPage.openCourse(course1.name) courseBrowserPage.assertTitleCorrect(course1) - } @After diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAnnouncementsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAnnouncementsE2ETest.kt new file mode 100644 index 0000000000..77c31f5877 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAnnouncementsE2ETest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2024 - 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.offline + +import android.util.Log +import androidx.test.espresso.Espresso +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.OfflineE2E +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData +import com.instructure.dataseeding.api.DiscussionTopicsApi +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils +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.After +import org.junit.Test +import java.lang.Thread.sleep + +@HiltAndroidTest +class OfflineAnnouncementsE2ETest : StudentTest() { + + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @OfflineE2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.ANNOUNCEMENTS, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) + fun testOfflineAnnouncementsE2E() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1, announcements = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val announcement = data.announcementsList[0] + + val lockedAnnouncement = DiscussionTopicsApi.createAnnouncement(course.id, teacher.token, locked = true) + + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + + Log.d(STEP_TAG, "Expand '${course.name}' course.") + manageOfflineContentPage.expandCollapseItem(course.name) + + Log.d(STEP_TAG, "Select the 'Announcements' of '${course.name}' course for sync. Click on the 'Sync' button.") + manageOfflineContentPage.changeItemSelectionState("Announcements") + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + turnOffConnectionViaADB() + OfflineTestUtils.waitForNetworkToGoOffline(device) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") + OfflineTestUtils.assertOfflineIndicator() + + Log.d(STEP_TAG, "Select '${course.name}' course and open 'Announcements' menu.") + dashboardPage.selectCourse(course) + courseBrowserPage.selectAnnouncements() + + Log.d(STEP_TAG,"Assert that '${announcement.title}' announcement is displayed.") + announcementListPage.assertTopicDisplayed(announcement.title) + + Log.d(STEP_TAG, "Assert that '${lockedAnnouncement.title}' announcement is really locked so that the 'locked' icon is displayed.") + announcementListPage.assertAnnouncementLocked(lockedAnnouncement.title) + + Log.d(STEP_TAG, "Select '${lockedAnnouncement.title}' announcement and assert if we are landing on the Discussion Details Page.") + 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.") + discussionDetailsPage.assertReplyButtonNotDisplayed() + Espresso.pressBack() + + Log.d(STEP_TAG,"Select '${announcement.title}' announcement and assert if we are landing on the Discussion Details Page.") + announcementListPage.selectTopic(announcement.title) + discussionDetailsPage.assertTitleText(announcement.title) + + Log.d(STEP_TAG, "Click on the 'Reply' button and assert that the 'No Internet Connection' dialog has displayed. Dismiss the dialog.") + discussionDetailsPage.clickReply() + OfflineTestUtils.assertNoInternetConnectionDialog() + OfflineTestUtils.dismissNoInternetConnectionDialog() + + Log.d(STEP_TAG,"Navigate back to Announcement List page. Click on Search button and type '${announcement.title}' to the search input field.") + Espresso.pressBack() + 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.") + 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.") + announcementListPage.searchable.clickOnClearSearchButton() + announcementListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) + announcementListPage.assertTopicDisplayed(announcement.title) + + Log.d(STEP_TAG,"Type the '${announcement.title}' announcement's title as search value to the search input field. Assert the the '${announcement.title}' announcement, but only that displayed as result.") + announcementListPage.searchable.typeToSearchBar(announcement.title) + sleep(3000) //We need this wait here to let make sure the search process has finished. + announcementListPage.assertTopicDisplayed(announcement.title) + announcementListPage.assertTopicNotDisplayed(lockedAnnouncement.title) + + Log.d(STEP_TAG,"Clear search input field value and assert if both the announcements are displayed again on the Announcement List Page.") + announcementListPage.searchable.clickOnClearSearchButton() + announcementListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) + announcementListPage.assertTopicDisplayed(announcement.title) + } + + @After + fun tearDown() { + Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") + turnOnConnectionViaADB() + } +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt index 5ea2aeb906..017eed51df 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt @@ -18,13 +18,13 @@ package com.instructure.student.ui.e2e.offline import android.util.Log import androidx.test.espresso.Espresso -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils import com.instructure.student.ui.pages.CourseBrowserPage import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData @@ -32,59 +32,49 @@ import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.After import org.junit.Test -import java.lang.Thread.sleep @HiltAndroidTest class OfflineCourseBrowserE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.COURSE, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.COURSE, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflineCourseBrowserPageUnavailableE2E() { Log.d(PREPARATION_TAG,"Seeding data.") val data = seedData(students = 1, teachers = 1, courses = 1, announcements = 1) val student = data.studentsList[0] - val course1 = data.coursesList[0] - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val course = data.coursesList[0] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() Log.d(STEP_TAG, "Open global 'Manage Offline Content' page via the more menu of the Dashboard Page.") dashboardPage.openGlobalManageOfflineContentPage() - Log.d(STEP_TAG, "Select the entire '${course1.name}' course for sync. Click on the 'Sync' button.") - Log.d(STEP_TAG, "Expand '${course1.name}' course. Select only the 'Announcements' of the '${course1.name}' course. Click on the 'Sync' button and confirm the sync process.") - manageOfflineContentPage.expandCollapseItem(course1.name) + Log.d(STEP_TAG, "Expand '${course.name}' course. Select only the 'Announcements' of the '${course.name}' course. Click on the 'Sync' button and confirm the sync process.") + manageOfflineContentPage.expandCollapseItem(course.name) manageOfflineContentPage.changeItemSelectionState("Announcements") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + OfflineTestUtils.waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() - Log.d(STEP_TAG, "Select '${course1.name}' course and open 'Announcements' menu.") - sleep(5000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - dashboardPage.selectCourse(course1) + Log.d(STEP_TAG, "Select '${course.name}' course and open 'Announcements' menu.") + dashboardPage.selectCourse(course) Log.d(STEP_TAG, "Assert that only the 'Announcements' tab is enabled because it is the only one which has been synced, and assert that all the other, previously synced tabs are disabled, because they weren't synced now.") var enabledTabs = arrayOf("Announcements") @@ -95,32 +85,27 @@ class OfflineCourseBrowserE2ETest : StudentTest() { Log.d(STEP_TAG, "Navigate back to Dashboard Page.Turn back on the Wi-Fi and Mobile Data on the device, and wait for it to come online.") Espresso.pressBack() turnOnConnectionViaADB() - dashboardPage.waitForNetworkComeBack() + dashboardPage.waitForOfflineIndicatorNotDisplayed() Log.d(STEP_TAG, "Open global 'Manage Offline Content' page via the more menu of the Dashboard Page.") dashboardPage.openGlobalManageOfflineContentPage() - Log.d(STEP_TAG, "Deselect the entire '${course1.name}' course for sync.") - manageOfflineContentPage.changeItemSelectionState(course1.name) + Log.d(STEP_TAG, "Deselect the entire '${course.name}' course for sync.") + manageOfflineContentPage.changeItemSelectionState(course.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Log.d(STEP_TAG, "Select '${course1.name}' course and open 'Announcements' menu.") - sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) - dashboardPage.selectCourse(course1) + Log.d(STEP_TAG, "Select '${course.name}' course and open 'Announcements' menu.") + OfflineTestUtils.waitForNetworkToGoOffline(device) + + Log.d(STEP_TAG, "Select '${course.name}' course.") + dashboardPage.selectCourse(course) Log.d(STEP_TAG, "Assert that the 'Google Drive' and 'Collaborations' tabs are disabled because they aren't supported in offline mode, but the rest of the tabs are enabled because the whole course has been synced.") enabledTabs = arrayOf("Announcements", "Discussions", "Grades", "People", "Syllabus", "BigBlueButton") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt index 4be5df3180..c49034b82d 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt @@ -27,6 +27,7 @@ import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData import com.instructure.student.ui.utils.tokenLogin @@ -36,13 +37,14 @@ import org.junit.Test @HiltAndroidTest class OfflineDashboardE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflineDashboardE2E() { Log.d(PREPARATION_TAG,"Seeding data.") @@ -52,10 +54,7 @@ class OfflineDashboardE2ETest : StudentTest() { val course2 = data.coursesList[1] val testAnnouncement = data.announcementsList[0] - Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -69,20 +68,13 @@ class OfflineDashboardE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState(course1.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course1.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() @@ -126,18 +118,13 @@ class OfflineDashboardE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState(course.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - device.waitForIdle() + waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt new file mode 100644 index 0000000000..1670437cc1 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2024 - 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.offline + +import android.util.Log +import androidx.test.espresso.Espresso +import com.google.android.material.checkbox.MaterialCheckBox +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.OfflineE2E +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData +import com.instructure.dataseeding.api.FileFolderApi +import com.instructure.dataseeding.model.FileUploadType +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils +import com.instructure.student.ui.utils.StudentTest +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.After +import org.junit.Test + +@HiltAndroidTest +class OfflineFilesE2ETest : StudentTest() { + + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @OfflineE2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.FILES, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) + fun testOfflineFilesE2E() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(teachers = 1, courses = 1, students = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + val testCourseFolderName = "Goodya" + Log.d(PREPARATION_TAG, "Create a course folder within the 'Files' tab with the name: '$testCourseFolderName'.") + val courseRootFolder = FileFolderApi.getCourseRootFolder(course.id, teacher.token) + val courseTestFolder = FileFolderApi.createCourseFolder(courseRootFolder.id, teacher.token, testCourseFolderName) + + Log.d(PREPARATION_TAG, "Create a (text) file within the root folder (so the 'Files' tab file list) of the '${course.name}' course.") + val rootFolderTestTextFile = uploadTextFile(courseRootFolder.id, token = teacher.token, fileUploadType = FileUploadType.COURSE_FILE) + + Log.d(PREPARATION_TAG, "Create a (text) file within the '${courseTestFolder.name}' folder of the '${course.name}' course.") + val courseTestFolderTextFile = uploadTextFile(courseTestFolder.id, token = teacher.token, fileUploadType = FileUploadType.COURSE_FILE) + + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + + Log.d(STEP_TAG, "Assert that the '${course.name}' course's checkbox state is 'Unchecked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course.name, MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Expand the course. Select the 'Files' of '${course.name}' course for sync. Click on the 'Sync' button.") + manageOfflineContentPage.expandCollapseItem(course.name) + manageOfflineContentPage.changeItemSelectionState("Files") + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + turnOffConnectionViaADB() + OfflineTestUtils.waitForNetworkToGoOffline(device) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Select '${course.name}' course and click on 'Files' tab to navigate to the File List Page.") + dashboardPage.selectCourse(course) + courseBrowserPage.selectFiles() + + Log.d(STEP_TAG, "Assert that under the 'Files' tab there are 2 items, a folder and a file which has been seeded recently, and there is 1 item within the folder.") + fileListPage.assertFileListCount(2) + fileListPage.assertFolderSize(courseTestFolder.name, 1) + + Log.d(STEP_TAG, "Assert that the folder's name is '${courseTestFolder.name}' and the file's name is '${rootFolderTestTextFile.fileName}'.") + fileListPage.assertItemDisplayed(rootFolderTestTextFile.fileName) + fileListPage.assertItemDisplayed(courseTestFolder.name) + + Log.d(STEP_TAG, "Open '${courseTestFolder.name}' folder and assert that the '${courseTestFolderTextFile.fileName}' file is displayed within it. Navigate back to File List Page.") + fileListPage.selectItem(courseTestFolder.name) + fileListPage.assertItemDisplayed(courseTestFolderTextFile.fileName) + Espresso.pressBack() + + Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${rootFolderTestTextFile.fileName}', the file's name to the search input field.") + fileListPage.searchable.clickOnSearchButton() + fileListPage.searchable.typeToSearchBar(rootFolderTestTextFile.fileName) + + Log.d(STEP_TAG, "Assert that only 1 file matches for the search text, and it is '${rootFolderTestTextFile.fileName}', and no directories has been shown in the result.") + fileListPage.assertSearchResultCount(1) + fileListPage.assertSearchItemDisplayed(rootFolderTestTextFile.fileName) + fileListPage.assertItemNotDisplayed(testCourseFolderName) + Espresso.closeSoftKeyboard() + } + + @After + fun tearDown() { + Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") + turnOnConnectionViaADB() + } + +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt index 16243ecf4d..84bcbdd3be 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt @@ -20,8 +20,10 @@ import android.util.Log import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.assertNoInternetConnectionDialog import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.assertOfflineIndicator import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.dismissNoInternetConnectionDialog @@ -34,31 +36,35 @@ import org.junit.Test @HiltAndroidTest class OfflineLeftSideMenuE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.LEFT_SIDE_MENU, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.LEFT_SIDE_MENU, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflineLeftSideMenuUnavailableFunctionsE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 2, announcements = 1) + val data = seedData(students = 1, teachers = 1, courses = 1, announcements = 1) val student = data.studentsList[0] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() + OfflineTestUtils.waitForNetworkToGoOffline(device) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") assertOfflineIndicator() - Log.d(STEP_TAG, "Open Left Side Menu by clicking on the 'hamburger icon' on the Dashboard Page.") + Log.d(STEP_TAG, "Open Left Side Menu by clicking on the 'hamburger/kebab icon' on the Dashboard Page.") dashboardPage.openLeftSideMenu() Log.d(STEP_TAG, "Assert that the offline indicator is displayed below the user info within the header.") @@ -89,7 +95,6 @@ class OfflineLeftSideMenuE2ETest : StudentTest() { leftSideNavigationDrawerPage.clickHelpMenu() assertNoInternetConnectionDialog() dismissNoInternetConnectionDialog() - } @After diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt index d8aee5a445..810839655b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt @@ -26,6 +26,7 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.assertNoInternetConnectionDialog import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.dismissNoInternetConnectionDialog +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData import dagger.hilt.android.testing.HiltAndroidTest @@ -35,13 +36,14 @@ import java.lang.Thread.sleep @HiltAndroidTest class OfflineLoginE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @OfflineE2E @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.LOGIN, TestCategory.E2E, SecondaryFeatureCategory.CHANGE_USER) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.LOGIN, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflineChangeUserE2E() { Log.d(PREPARATION_TAG, "Seeding data.") @@ -49,7 +51,7 @@ class OfflineLoginE2ETest : StudentTest() { val student1 = data.studentsList[0] val student2 = data.studentsList[1] - Log.d(STEP_TAG, "Login with user: ${student1.name}, login id: ${student1.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student1.name}', login id: '${student1.loginId}'.") loginWithUser(student1) dashboardPage.waitForRender() @@ -59,8 +61,10 @@ class OfflineLoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() - Log.d(STEP_TAG, "Login with user: ${student2.name}, login id: ${student2.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student2.name}', login id: '${student2.loginId}'.") loginWithUser(student2, true) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() Log.d(STEP_TAG, "Assert that the Offline indicator is not displayed because we are in online mode yet.") @@ -68,11 +72,12 @@ class OfflineLoginE2ETest : StudentTest() { Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() + waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() - Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that ${student1.name} and ${student2.name} students are displayed within the previous login section.") + Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that '${student1.name}' and '${student2.name}' students are displayed within the previous login section.") loginLandingPage.assertDisplaysPreviousLogins() loginLandingPage.assertPreviousLoginUserDisplayed(student1.name) loginLandingPage.assertPreviousLoginUserDisplayed(student2.name) @@ -87,7 +92,7 @@ class OfflineLoginE2ETest : StudentTest() { assertNoInternetConnectionDialog() dismissNoInternetConnectionDialog() - Log.d(STEP_TAG, "Login with the previous user, ${student1.name}, with one click, by clicking on the user's name on the bottom.") + Log.d(STEP_TAG, "Login with the previous user, '${student1.name}', with one click, by clicking on the user's name on the bottom.") loginLandingPage.loginWithPreviousUser(student1) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Assert that the offline indicator is displayed to ensure we are in offline mode, and change user function is supported.") @@ -97,13 +102,12 @@ class OfflineLoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() - Log.d(STEP_TAG, "Login with the previous user, ${student2.name}, with one click, by clicking on the user's name on the bottom.") + Log.d(STEP_TAG, "Login with the previous user, '${student2.name}', with one click, by clicking on the user's name on the bottom.") loginLandingPage.loginWithPreviousUser(student2) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Assert that the offline indicator is displayed to ensure we are in offline mode, and change user function is supported.") dashboardPage.waitForRender() dashboardPage.assertOfflineIndicatorDisplayed() - } private fun loginWithUser(user: CanvasUserApiModel, lastSchoolSaved: Boolean = false) { @@ -119,7 +123,7 @@ class OfflineLoginE2ETest : StudentTest() { loginLandingPage.clickFindMySchoolButton() } - Log.d(STEP_TAG,"Enter domain: ${user.domain}.") + Log.d(STEP_TAG,"Enter domain: '${user.domain}'.") loginFindSchoolPage.enterDomain(user.domain) Log.d(STEP_TAG,"Click on 'Next' button on the Toolbar.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt new file mode 100644 index 0000000000..c2a949ce28 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2024 - 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.offline + +import android.util.Log +import androidx.test.espresso.Espresso +import androidx.test.espresso.web.webdriver.Locator +import com.google.android.material.checkbox.MaterialCheckBox +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.OfflineE2E +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData +import com.instructure.dataseeding.api.PagesApi +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.assertOfflineIndicator +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline +import com.instructure.student.ui.pages.WebViewTextCheck +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.After +import org.junit.Test + +@HiltAndroidTest +class OfflinePagesE2ETest : StudentTest() { + + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @OfflineE2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.PAGES, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) + fun testOfflinePagesE2E() { + + 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] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG,"Seed an UNPUBLISHED page for '${course.name}' course.") + val pageUnpublished = PagesApi.createCoursePage(course.id, teacher.token, published = false) + + Log.d(PREPARATION_TAG,"Seed a PUBLISHED page for '${course.name}' course.") + val pagePublished = PagesApi.createCoursePage(course.id, teacher.token, editingRoles = "teachers,students", body = "

Regular Page Text

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

Regular Page Text

") + + Log.d(PREPARATION_TAG,"Seed a PUBLISHED, FRONT page for '${course.name}' course.") + val pagePublishedFront = PagesApi.createCoursePage(course.id, teacher.token, 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, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + + Log.d(STEP_TAG, "Assert that the '${course.name}' course's checkbox state is 'Unchecked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course.name, MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Expand the course. Select the 'Pages' of '${course.name}' course for sync. Click on the 'Sync' button.") + manageOfflineContentPage.expandCollapseItem(course.name) + manageOfflineContentPage.changeItemSelectionState("Pages") + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + turnOffConnectionViaADB() + waitForNetworkToGoOffline(device) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Select '${course.name}' course and click on 'Pages' tab to navigate to the Page List Page.") + dashboardPage.selectCourse(course) + courseBrowserPage.selectPages() + + Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Page List Page.") + assertOfflineIndicator() + + 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, "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")) + + Log.d(STEP_TAG,"Navigate back to Pages page.") + Espresso.pressBack() + + 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. Assert that the 'No Internet Connection' dialog has displayed. Dismiss the dialog by accepting it.") + canvasWebViewPage.clickEditPencilIcon() + OfflineTestUtils.assertNoInternetConnectionDialog() + OfflineTestUtils.dismissNoInternetConnectionDialog() + + 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. Assert that the 'No Internet Connection' dialog has displayed. Dismiss the dialog by accepting it.") + canvasWebViewPage.clickEditPencilIcon() + OfflineTestUtils.assertNoInternetConnectionDialog() + OfflineTestUtils.dismissNoInternetConnectionDialog() + } + + @After + fun tearDown() { + Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") + turnOnConnectionViaADB() + } + +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt index 707757c566..a48ff20b91 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt @@ -18,11 +18,10 @@ package com.instructure.student.ui.e2e.offline import android.util.Log import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils @@ -35,13 +34,14 @@ import org.junit.Test @HiltAndroidTest class OfflinePeopleE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.PEOPLE, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.PEOPLE, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflinePeopleE2E() { Log.d(PREPARATION_TAG,"Seeding data.") @@ -50,35 +50,27 @@ class OfflinePeopleE2ETest : StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() Log.d(STEP_TAG, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + Log.d(STEP_TAG, "Expand '${course.name}' course.") manageOfflineContentPage.expandCollapseItem(course.name) - Log.d(STEP_TAG, "Select the entire '${course.name}' course for sync. Click on the 'Sync' button.") + + Log.d(STEP_TAG, "Select the 'People' of '${course.name}' course for sync. Click on the 'Sync' button.") manageOfflineContentPage.changeItemSelectionState("People") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + OfflineTestUtils.waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() @@ -86,9 +78,6 @@ class OfflinePeopleE2ETest : StudentTest() { Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") OfflineTestUtils.assertOfflineIndicator() - Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") - dashboardPage.assertCourseOfflineSyncIconVisible(course.name) - Log.d(STEP_TAG, "Select '${course.name}' course and open 'People' menu.") dashboardPage.selectCourse(course) courseBrowserPage.selectPeople() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt index a424546711..9bb2940fa9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt @@ -18,12 +18,11 @@ package com.instructure.student.ui.e2e.offline import android.util.Log import androidx.test.espresso.Espresso -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice import com.google.android.material.checkbox.MaterialCheckBox import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils @@ -36,26 +35,26 @@ import org.junit.Test @HiltAndroidTest class OfflineSyncProgressE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.SYNC_PROGRESS, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.SYNC_PROGRESS, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflineGlobalCourseSyncProgressE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 2, announcements = 1) + val data = seedData(students = 1, teachers = 1, courses = 4, announcements = 3, discussions = 5, syllabusBody = "Syllabus body") val student = data.studentsList[0] val course1 = data.coursesList[0] val course2 = data.coursesList[1] + val course3 = data.coursesList[2] + val course4 = data.coursesList[3] val testAnnouncement = data.announcementsList[0] - Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -67,41 +66,55 @@ class OfflineSyncProgressE2ETest : StudentTest() { Log.d(STEP_TAG, "Select the entire '${course1.name}' course for sync. Click on the 'Sync' button.") manageOfflineContentPage.changeItemSelectionState(course1.name) + manageOfflineContentPage.changeItemSelectionState(course2.name) + manageOfflineContentPage.changeItemSelectionState(course3.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") + Log.d(STEP_TAG, "Wait for the Dashboard to be rendered.") dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and click on it to enter the Sync Progress Page.") - dashboardPage.waitForSyncProgressStartingNotification() + Log.d(STEP_TAG, "Click on the Dashboard notification to open the Sync Progress Page.") dashboardPage.clickOnSyncProgressNotification() - Log.d(STEP_TAG, "Assert that the Sync Progress has started.") - syncProgressPage.waitForDownloadStarting() - Log.d(STEP_TAG, "Assert that the Sync Progress has been successful (so to have the success title and the course success indicator).") syncProgressPage.assertDownloadProgressSuccessDetails() syncProgressPage.assertCourseSyncedSuccessfully(course1.name) + syncProgressPage.assertCourseSyncedSuccessfully(course2.name) + syncProgressPage.assertCourseSyncedSuccessfully(course3.name) + + Log.d(STEP_TAG, "Get the sum of '${course1.name}', '${course2.name}' and '${course3.name}' courses' sizes and assert that the sum number is displayed under the progress bar.") + val sumOfSyncedCourseSizes = syncProgressPage.getCourseSize(course1.name) + syncProgressPage.getCourseSize(course2.name) + syncProgressPage.getCourseSize(course3.name) + syncProgressPage.assertSumOfCourseSizes(sumOfSyncedCourseSizes) + + Log.d(STEP_TAG, "Expand '${course1.name}' course and assert a few tabs (for example) to ensure they synced well and the success indicator is displayed in their rows.") + syncProgressPage.expandCollapseCourse(course1.name) + syncProgressPage.assertCourseTabSynced("Syllabus") + syncProgressPage.assertCourseTabSynced("Announcements") + syncProgressPage.assertCourseTabSynced("Grades") + device.waitForIdle() Log.d(STEP_TAG, "Navigate back to Dashboard Page and wait for it to be rendered.") Espresso.pressBack() + Log.d(STEP_TAG, "Assert that the offline sync icon is displayed in online mode on the synced courses' course cards.") + dashboardPage.assertCourseOfflineSyncIconVisible(course1.name) + dashboardPage.assertCourseOfflineSyncIconVisible(course2.name) + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + OfflineTestUtils.waitForNetworkToGoOffline(device) + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") OfflineTestUtils.assertOfflineIndicator() - Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced courses' course card.") dashboardPage.assertCourseOfflineSyncIconVisible(course1.name) - dashboardPage.assertCourseOfflineSyncIconGone(course2.name) + dashboardPage.assertCourseOfflineSyncIconVisible(course2.name) + dashboardPage.assertCourseOfflineSyncIconVisible(course3.name) + dashboardPage.assertCourseOfflineSyncIconGone(course4.name) Log.d(STEP_TAG, "Select '${course1.name}' course and open 'Announcements' menu.") dashboardPage.selectCourse(course1) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt index 67bae6de61..6efa56db19 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt @@ -20,6 +20,7 @@ import android.util.Log import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.pandautils.R @@ -33,20 +34,21 @@ import org.junit.Test @HiltAndroidTest class OfflineSyncSettingsE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.OFFLINE_CONTENT, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.OFFLINE_CONTENT, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun offlineSyncSettingsE2ETest() { Log.d(PREPARATION_TAG,"Seeding data.") val data = seedData(students = 1, teachers = 1, courses = 2, announcements = 1) val student = data.studentsList[0] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -110,13 +112,13 @@ class OfflineSyncSettingsE2ETest : StudentTest() { Log.d(STEP_TAG, "Click 'Find My School' button.") loginLandingPage.clickFindMySchoolButton() - Log.d(STEP_TAG, "Enter domain: ${student.domain}.") + Log.d(STEP_TAG, "Enter domain: '${student.domain}'.") loginFindSchoolPage.enterDomain(student.domain) Log.d(STEP_TAG, "Click on 'Next' button on the Toolbar.") loginFindSchoolPage.clickToolbarNextMenuItem() - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") loginSignInPage.loginAs(student) dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt index 0696eec9b3..c7dbd9c204 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt @@ -78,4 +78,10 @@ object OfflineTestUtils { hasSibling(withId(R.id.topPanel) + hasDescendant(withText(R.string.noInternetConnectionTitle))))).click() } + + fun waitForNetworkToGoOffline(device: UiDevice) { + Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. + device.waitForIdle() + device.waitForWindowUpdate(null, 10000) + } } \ No newline at end of file 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 index be17d15451..1c40730be3 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 - present Instructure, Inc. + * 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. @@ -20,12 +20,12 @@ 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.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.dataseeding.api.GroupsApi import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData import com.instructure.student.ui.utils.tokenLogin 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 6854424b64..3d89f7af28 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 @@ -20,6 +20,7 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.canvas.espresso.checkToastText import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment import com.instructure.canvas.espresso.mockCanvas.addAssignmentsToGroups @@ -28,6 +29,8 @@ 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.dataseeding.model.SubmissionType +import com.instructure.student.R import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.routeTo import com.instructure.student.ui.utils.tokenLogin @@ -53,7 +56,7 @@ class AssignmentDetailsInteractionTest : StudentTest() { val course = data.courses.values.first() val student = data.students[0] val token = data.tokenFor(student)!! - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_URL) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL)) data.addSubmissionForAssignment( assignmentId = assignment.id, userId = data.users.values.first().id, @@ -118,7 +121,7 @@ class AssignmentDetailsInteractionTest : StudentTest() { @Test @TestMetaData(Priority.COMMON, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) - fun testDisplayBookmarMenu() { + fun testDisplayBookmarkMenu() { val data = setUpData() goToAssignmentList() val assignmentList = data.assignments @@ -349,6 +352,190 @@ class AssignmentDetailsInteractionTest : StudentTest() { assignmentDetailsPage.assertScoreNotDisplayed() } + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION, SecondaryFeatureCategory.SUBMISSIONS_MULTIPLE_TYPE) + fun testSubmission_multipleSubmissionType() { + val data = MockCanvas.init( + studentCount = 1, + courseCount = 1 + ) + + val course = data.courses.values.first() + val student = data.students[0] + val token = data.tokenFor(student)!! + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY, Assignment.SubmissionType.ONLINE_UPLOAD, Assignment.SubmissionType.MEDIA_RECORDING, Assignment.SubmissionType.DISCUSSION_TOPIC, Assignment.SubmissionType.ONLINE_URL)) + data.addSubmissionForAssignment( + assignmentId = assignment.id, + userId = data.users.values.first().id, + type = Assignment.SubmissionType.ONLINE_URL.apiString + ) + tokenLogin(data.domain, token, student) + routeTo("courses/${course.id}/assignments", data.domain) + assignmentListPage.waitForPage() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickSubmit() + + assignmentDetailsPage.assertSubmissionTypeDisplayed("Text Entry") + assignmentDetailsPage.assertSubmissionTypeDisplayed("Website URL") + assignmentDetailsPage.assertSubmissionTypeDisplayed("File Upload") + assignmentDetailsPage.assertSubmissionTypeDisplayed("Media Recording") + + //Try 1 submission to check if it's possible to submit even when there are multiple submission types available. + assignmentDetailsPage.selectSubmissionType(SubmissionType.ONLINE_URL) + urlSubmissionUploadPage.submitText("https://google.com") + assignmentDetailsPage.assertStatusSubmitted() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testReminderSectionIsNotVisibleWhenThereIsNoFutureDueDate() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, -1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertReminderSectionNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testReminderSectionIsVisibleWhenThereIsFutureDueDate() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertReminderSectionDisplayed() + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testAddReminder() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.selectTimeOption("1 Hour Before") + + assignmentDetailsPage.assertReminderDisplayedWithText("1 Hour Before") + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testRemoveReminder() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.selectTimeOption("1 Hour Before") + + assignmentDetailsPage.assertReminderDisplayedWithText("1 Hour Before") + + assignmentDetailsPage.removeReminderWithText("1 Hour Before") + + assignmentDetailsPage.assertReminderNotDisplayedWithText("1 Hour Before") + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testAddCustomReminder() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.clickCustom() + assignmentDetailsPage.assertDoneButtonIsDisabled() + assignmentDetailsPage.fillQuantity("15") + assignmentDetailsPage.assertDoneButtonIsDisabled() + assignmentDetailsPage.clickHoursBefore() + assignmentDetailsPage.clickDone() + + assignmentDetailsPage.assertReminderDisplayedWithText("15 Hours Before") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testAddReminderInPastShowsError() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.MINUTE, 30) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.selectTimeOption("1 Hour Before") + + checkToastText(R.string.reminderInPast, activityRule.activity) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testAddReminderForTheSameTimeShowsError() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.selectTimeOption("1 Hour Before") + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.selectTimeOption("1 Hour Before") + + checkToastText(R.string.reminderAlreadySet, activityRule.activity) + } + + @Test + @TestMetaData(Priority.NICE_TO_HAVE, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testAddReminderForTheSameTimeWithDifferentMeasureOfTimeShowsError() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 10) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.selectTimeOption("1 Week Before") + assignmentDetailsPage.clickAddReminder() + + assignmentDetailsPage.clickCustom() + assignmentDetailsPage.fillQuantity("7") + assignmentDetailsPage.clickDaysBefore() + assignmentDetailsPage.clickDone() + + checkToastText(R.string.reminderAlreadySet, activityRule.activity) + } + 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( @@ -401,7 +588,7 @@ class AssignmentDetailsInteractionTest : StudentTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", pointsPossible = maxScore, ) 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 38fd1537a7..fab2876a38 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 @@ -226,7 +226,7 @@ class AssignmentListInteractionTest : StudentTest() { repeat(assignmentCount) { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) assignmentList.add(assignment) } @@ -254,7 +254,7 @@ class AssignmentListInteractionTest : StudentTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", pointsPossible = maxScore, ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/BookmarkInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/BookmarkInteractionTest.kt index 78c0a552c3..ec8cac9747 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/BookmarkInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/BookmarkInteractionTest.kt @@ -115,7 +115,7 @@ class BookmarkInteractionTest : StudentTest() { val course = data.courses.values.first() val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) val token = data.tokenFor(student)!! 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 index a305f58b66..f989450db0 100644 --- 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 @@ -248,7 +248,7 @@ class CourseGradesInteractionTest : StudentTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", pointsPossible = maxScore, ) 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 dcdf27fe50..25310d5be8 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 @@ -608,7 +608,7 @@ class DiscussionsInteractionTest : StudentTest() { // Add an assignment val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), name = assignmentName, pointsPossible = 12 ) @@ -649,7 +649,7 @@ class DiscussionsInteractionTest : StudentTest() { // Add an assignment val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), name = assignmentName, pointsPossible = 12 ) @@ -694,7 +694,7 @@ class DiscussionsInteractionTest : StudentTest() { // Add an assignment val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), name = assignmentName, pointsPossible = 12 ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryDashboardInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryDashboardInteractionTest.kt index 4f0f1f8db9..e1762cd4be 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryDashboardInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryDashboardInteractionTest.kt @@ -34,7 +34,7 @@ class ElementaryDashboardInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testNavigateToElementaryDashboard() { // User should be able to tap and navigate to dashboard page goToElementaryDashboard(courseCount = 1, favoriteCourseCount = 1) @@ -46,7 +46,7 @@ class ElementaryDashboardInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testTabsNavigation() { goToElementaryDashboard(courseCount = 1, favoriteCourseCount = 1) elementaryDashboardPage.assertElementaryTabVisibleAndSelected(ElementaryDashboardPage.ElementaryTabType.HOMEROOM) @@ -70,7 +70,7 @@ class ElementaryDashboardInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOnlyElementarySpecificNavigationItemsShownInTheNavigationDrawer() { goToElementaryDashboard(courseCount = 1, favoriteCourseCount = 1) elementaryDashboardPage.openDrawer() 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 36923ce040..bad0d87003 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 @@ -39,7 +39,7 @@ class ElementaryGradesInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowGrades() { val data = createMockData(courseCount = 3) goToGradesTab(data) @@ -52,7 +52,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testRefresh() { val data = createMockData(courseCount = 1) goToGradesTab(data) @@ -72,7 +72,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenCourseGrades() { val data = createMockData(courseCount = 3) goToGradesTab(data) @@ -90,7 +90,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testChangeGradingPeriod() { val data = createMockData(courseCount = 3, withGradingPeriods = true) goToGradesTab(data) @@ -104,7 +104,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testEmptyView() { val data = createMockData(homeroomCourseCount = 1) goToGradesTab(data) @@ -114,7 +114,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowPercentageOnlyIfNoAlphabeticalGrade() { val data = createMockData(courseCount = 1) goToGradesTab(data) @@ -135,7 +135,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testDontShowProgressWhenQuantitativeDataIsRestricted() { val data = createMockData(courseCount = 1) goToGradesTab(data) @@ -157,7 +157,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testDontShowGradeWhenQuantitativeDataIsRestrictedAndThereIsOnlyScore() { val data = createMockData(courseCount = 1) goToGradesTab(data) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt index a139f5d4c9..fc2ed7ace6 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt @@ -41,7 +41,7 @@ class HomeroomInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testAnnouncementsAndCoursesShowUpOnHomeroom() { val data = createMockDataWithHomeroomCourse(courseCount = 3) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -66,7 +66,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOnlyCoursesShowUpOnHomeroomIfNoHomeroomAnnouncement() { val data = createMockDataWithHomeroomCourse(courseCount = 3) @@ -87,7 +87,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOnlyAnnouncementShowsUpOnHomeroomIfNoCourses() { val data = createMockDataWithHomeroomCourse() val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -106,7 +106,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenCourse() { val data = createMockDataWithHomeroomCourse(courseCount = 3) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -127,7 +127,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testRefreshAfterEnrolledToCourses() { val data = createMockDataWithHomeroomCourse() @@ -160,7 +160,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenHomeroomCourseAnnouncements() { val data = createMockDataWithHomeroomCourse(courseCount = 3, homeroomCourseCount = 2) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -184,7 +184,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenCourseAnnouncements() { val data = createMockDataWithHomeroomCourse(courseCount = 1) @@ -203,7 +203,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowCourseCardWithAnnouncement() { val data = createMockDataWithHomeroomCourse(courseCount = 3) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -224,7 +224,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testDueTodayAndMissingAssignments() { val data = createMockDataWithHomeroomCourse(courseCount = 1) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -234,8 +234,8 @@ class HomeroomInteractionTest : StudentTest() { val courses = data.courses.values.filter { !it.homeroomCourse } - data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) - data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) + data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) goToHomeroomTab(data) @@ -249,7 +249,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenAssignments() { val data = createMockDataWithHomeroomCourse(courseCount = 1) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -259,8 +259,8 @@ class HomeroomInteractionTest : StudentTest() { val courses = data.courses.values.filter { !it.homeroomCourse } - val assignment1 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) - data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + val assignment1 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) + data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) goToHomeroomTab(data) @@ -272,7 +272,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testEmptyState() { val data = createMockDataWithHomeroomCourse() 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 28a43cb393..2262539a98 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 @@ -44,7 +44,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowCalendarEvents() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] @@ -59,12 +59,12 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowAssignment() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) val assignmentScheduleItem = data.addAssignmentCalendarEvent(course.id, 2.days.fromNow.iso8601, assignment.name!!, assignment.description!!, true, assignment) goToImportantDatesTab(data) @@ -75,7 +75,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testEmptyView() { val data = createMockData(courseCount = 1) @@ -86,7 +86,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testPullToRefresh() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] @@ -107,7 +107,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenCalendarEvent() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] @@ -127,12 +127,12 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenAssignment() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) val assignmentScheduleItem = data.addAssignmentCalendarEvent(course.id, 2.days.fromNow.iso8601, assignment.name!!, assignment.description!!, true, assignment) goToImportantDatesTab(data) @@ -147,12 +147,12 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowMultipleCalendarEventsOnSameDay() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) data.addAssignmentCalendarEvent(course.id, 2.days.fromNow.iso8601, assignment.name!!, assignment.description!!, true, assignment) val calendarEvent = data.addCourseCalendarEvent(course.id, 2.days.fromNow.iso8601, "Important event", "Important event description", true) @@ -171,12 +171,12 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testMultipleCalendarEventsOnDifferentDays() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) val twoDaysFromNowEvent = data.addAssignmentCalendarEvent(course.id, 2.days.fromNow.iso8601, "Important event two days later", "Important event two days later description", true, assignment) val threeDaysFromNowEvent = data.addCourseCalendarEvent(course.id, 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 98af62da5a..489c813e44 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 @@ -207,6 +207,7 @@ class InAppUpdateInteractionTest : StudentTest() { @Test fun showImmediateFlow() { + updatePrefs.clearPrefs() with(appUpdateManager) { setUpdateAvailable(400) setUpdatePriority(4) @@ -270,8 +271,8 @@ class InAppUpdateInteractionTest : StudentTest() { } @Test - @Stub("Unstable, there is a ticket to fix this") fun flexibleUpdateCompletesIfAppRestarts() { + updatePrefs.clearPrefs() with(appUpdateManager) { setUpdateAvailable(400) setUpdatePriority(2) @@ -288,6 +289,7 @@ class InAppUpdateInteractionTest : StudentTest() { @Test fun immediateUpdateCompletion() { + updatePrefs.clearPrefs() with(appUpdateManager) { setUpdateAvailable(400) setUpdatePriority(4) @@ -307,6 +309,7 @@ class InAppUpdateInteractionTest : StudentTest() { @Test fun hideImmediateUpdateFlowIfUserCancels() { + updatePrefs.clearPrefs() with(appUpdateManager) { setUpdateAvailable(400) setUpdatePriority(4) 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 2114a83fdc..a4af822a17 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 @@ -81,7 +81,7 @@ class ModuleInteractionTest : StudentTest() { // Create an assignment and add it as a module item assignment = data.addAssignment( courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) data.addItemToModule( course = course1, @@ -324,7 +324,7 @@ class ModuleInteractionTest : StudentTest() { // Create an assignment and add it as a module item assignment = data.addAssignment( courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) data.addItemToModule( course = course1, @@ -385,7 +385,7 @@ class ModuleInteractionTest : StudentTest() { // Create an assignment and add it as a module item assignment = data.addAssignment( courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) data.addItemToModule( course = course1, @@ -501,7 +501,7 @@ class ModuleInteractionTest : StudentTest() { // And let's add an assignment to the new module var unavailableAssignment = data.addAssignment( courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(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( @@ -541,7 +541,7 @@ class ModuleInteractionTest : StudentTest() { // 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 + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) data.addItemToModule( course = course1, @@ -568,7 +568,7 @@ class ModuleInteractionTest : StudentTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = 10 ) @@ -595,7 +595,7 @@ class ModuleInteractionTest : StudentTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = 10 ) 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 index f11a079a02..adde4b4802 100644 --- 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 @@ -255,7 +255,7 @@ class NotificationInteractionTest : StudentTest() { repeat(numSubmissions) { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), gradingType = Assignment.gradingTypeToAPIString(gradingType).orEmpty(), pointsPossible = 20 ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PdfInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PdfInteractionTest.kt index 083442ccdd..8c61146e4e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PdfInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PdfInteractionTest.kt @@ -153,7 +153,7 @@ class PdfInteractionTest : StudentTest() { val pdfUrlElementId = "testLinkElement" val assignmentDescriptionHtml = """pdf baby!!!""" - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_UPLOAD, description = assignmentDescriptionHtml) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD), description = assignmentDescriptionHtml) assignmentListPage.waitForPage() assignmentListPage.refresh() 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 05d7ca62d1..333abfc1da 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 @@ -155,7 +155,7 @@ class PickerSubmissionUploadInteractionTest : StudentTest() { // Let's set up an assignment that requires an online upload val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_UPLOAD + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD) ) // Sign in diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ResourcesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ResourcesInteractionTest.kt index 42c1233741..c33e422e9c 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ResourcesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ResourcesInteractionTest.kt @@ -38,7 +38,7 @@ class ResourcesInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testImportantLinksAndActionItemsShowUpInResourcesScreen() { val data = createMockDataWithHomeroomCourse(courseCount = 2) @@ -67,7 +67,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOnlyActionItemsShowIfSyllabusIsEmpty() { val data = createMockDataWithHomeroomCourse(courseCount = 2) @@ -95,7 +95,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOnlyLtiToolsShowIfNoHomeroomCourse() { val data = createMockDataWithHomeroomCourse(courseCount = 2, homeroomCourseCount = 0) @@ -117,7 +117,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testRefresh() { val data = createMockDataWithHomeroomCourse(courseCount = 2, homeroomCourseCount = 0) @@ -152,7 +152,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenLtiToolShowsCourseSelector() { val data = createMockDataWithHomeroomCourse(courseCount = 2) @@ -175,7 +175,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenComposeMessageScreen() { val data = createMockDataWithHomeroomCourse(courseCount = 2) @@ -201,7 +201,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testImportantLinksForTwoCourses() { val data = createMockDataWithHomeroomCourse(courseCount = 2) @@ -225,7 +225,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testEmptyState() { val data = createMockDataWithHomeroomCourse(courseCount = 2, homeroomCourseCount = 0) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt index 58abd6a093..29eed58195 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt @@ -49,7 +49,7 @@ class ScheduleInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowCorrectHeaderItems() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -74,7 +74,7 @@ class ScheduleInteractionTest : StudentTest() { @Test @StubLandscape(description = "This is intentionally stubbed on landscape mode because the item view is too narrow, but that's not a bug, it's intentional.") - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowScheduledAssignments() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -83,7 +83,7 @@ class ScheduleInteractionTest : StudentTest() { courses[0].name = "Course 1" val currentDate = dateTimeProvider.getCalendar().time.toApiString() - val assignment1 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate, name = "Assignment 1") + val assignment1 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate, name = "Assignment 1") goToScheduleTab(data) schedulePage.scrollToPosition(10) @@ -93,7 +93,7 @@ class ScheduleInteractionTest : StudentTest() { @Test @StubLandscape(description = "This is intentionally stubbed on landscape mode because the item view is too narrow, but that's not a bug, it's intentional.") - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowMissingAssignments() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -101,7 +101,7 @@ class ScheduleInteractionTest : StudentTest() { val courses = data.courses.values.filter { !it.homeroomCourse } val currentDate = dateTimeProvider.getCalendar().time.toApiString() - val assignment1 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate) + val assignment1 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate) goToScheduleTab(data) schedulePage.scrollToPosition(12) @@ -109,7 +109,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowToDoEvents() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -125,7 +125,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testRefresh() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -140,8 +140,8 @@ class ScheduleInteractionTest : StudentTest() { schedulePage.assertNoScheduleItemDisplayed() val currentDate = dateTimeProvider.getCalendar().time.toApiString() - val assignment1 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate) - val assignment2 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate) + val assignment1 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate) + val assignment2 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate) schedulePage.scrollToPosition(0) schedulePage.refresh() @@ -154,7 +154,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testGoBack2Weeks() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -177,7 +177,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testGoForward2Weeks() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -200,7 +200,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenAssignment() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -209,7 +209,7 @@ class ScheduleInteractionTest : StudentTest() { courses[0].name = "Course 1" val currentDate = dateTimeProvider.getCalendar().time.toApiString() - val assignment = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate, name = "Assignment 1") + val assignment = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate, name = "Assignment 1") goToScheduleTab(data) schedulePage.scrollToPosition(9) @@ -220,7 +220,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenCourse() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -228,7 +228,7 @@ class ScheduleInteractionTest : StudentTest() { val courses = data.courses.values.filter { !it.homeroomCourse } val currentDate = dateTimeProvider.getCalendar().time.toApiString() - data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate) + data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate) goToScheduleTab(data) schedulePage.scrollToPosition(8) @@ -239,7 +239,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testMarkAsDone() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -256,7 +256,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testTodayButton() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -265,7 +265,7 @@ class ScheduleInteractionTest : StudentTest() { courses[0].name = "Course 1" val currentDate = dateTimeProvider.getCalendar().time.toApiString() - val assignment1 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate, name = "Assignment 1") + val assignment1 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate, name = "Assignment 1") goToScheduleTab(data) 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 f19c657307..87e4338e7b 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 @@ -24,6 +24,7 @@ import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import com.instructure.canvas.espresso.Stub +import com.instructure.canvas.espresso.StubCoverage import com.instructure.canvas.espresso.StubTablet import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment @@ -131,7 +132,7 @@ class ShareExtensionInteractionTest : StudentTest() { val uri = setupFileOnDevice("sample.jpg") val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val assignment = data.addAssignment(data.courses.values.first().id, submissionType = Assignment.SubmissionType.ONLINE_UPLOAD) + val assignment = data.addAssignment(data.courses.values.first().id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD)) login(student) device.pressHome() @@ -182,8 +183,8 @@ class ShareExtensionInteractionTest : StudentTest() { val uri = setupFileOnDevice("sample.jpg") val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - data.addAssignment(data.courses.values.first().id, submissionType = Assignment.SubmissionType.ONLINE_UPLOAD) - val assignment2 = data.addAssignment(data.courses.values.first().id, submissionType = Assignment.SubmissionType.ONLINE_UPLOAD) + data.addAssignment(data.courses.values.first().id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD)) + val assignment2 = data.addAssignment(data.courses.values.first().id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD)) login(student) device.pressHome() @@ -233,13 +234,14 @@ class ShareExtensionInteractionTest : StudentTest() { } @Test + @StubCoverage("Cannot init FileUploadWorker and OfflineSyncWorker") fun testFileAssignmentSubmission() { val data = createMockData() val student = data.students[0] val uri = setupFileOnDevice("sample.jpg") val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - data.addAssignment(data.courses.values.first().id, submissionType = Assignment.SubmissionType.ONLINE_UPLOAD) + data.addAssignment(data.courses.values.first().id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD)) login(student) device.pressHome() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SubmissionDetailsInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SubmissionDetailsInteractionTest.kt index afedd20532..30203de91a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SubmissionDetailsInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SubmissionDetailsInteractionTest.kt @@ -57,7 +57,7 @@ class SubmissionDetailsInteractionTest : StudentTest() { val data = getToCourse() val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = 10 ) @@ -102,7 +102,7 @@ class SubmissionDetailsInteractionTest : StudentTest() { val data = getToCourse() val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL) ) courseBrowserPage.selectAssignments() @@ -124,7 +124,7 @@ class SubmissionDetailsInteractionTest : StudentTest() { val data = getToCourse() val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL), userSubmitted = true ) @@ -169,7 +169,7 @@ class SubmissionDetailsInteractionTest : StudentTest() { val user = data.users.values.first() val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) // Some html for an attachment diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SyllabusInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SyllabusInteractionTest.kt index 7e8f5e321a..8b9b613563 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SyllabusInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SyllabusInteractionTest.kt @@ -15,6 +15,10 @@ */ package com.instructure.student.ui.interaction +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment import com.instructure.canvas.espresso.mockCanvas.addCourseCalendarEvent @@ -26,10 +30,6 @@ 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.canvas.espresso.FeatureCategory -import com.instructure.canvas.espresso.Priority -import com.instructure.canvas.espresso.TestCategory -import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -87,7 +87,7 @@ class SyllabusInteractionTest : StudentTest() { repeat(assignmentCount) { data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 2.days.fromNow.iso8601 ) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/TodoInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/TodoInteractionTest.kt index 5b48a14d07..4e0d5abc4a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/TodoInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/TodoInteractionTest.kt @@ -16,8 +16,12 @@ package com.instructure.student.ui.interaction import androidx.test.espresso.Espresso +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.StubLandscape import com.instructure.canvas.espresso.StubMultiAPILevel +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment import com.instructure.canvas.espresso.mockCanvas.addQuizToCourse @@ -28,10 +32,6 @@ import com.instructure.canvasapi2.models.Quiz import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.canvas.espresso.FeatureCategory -import com.instructure.canvas.espresso.Priority -import com.instructure.canvas.espresso.TestCategory -import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -104,7 +104,7 @@ class TodoInteractionTest : StudentTest() { for(course in data.courses.values) { assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 1.days.fromNow.iso8601 ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/UserFilesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/UserFilesInteractionTest.kt index 2e579c8a33..b5493d3b83 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/UserFilesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/UserFilesInteractionTest.kt @@ -26,14 +26,16 @@ import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasType +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule -import com.instructure.canvas.espresso.Stub -import com.instructure.canvas.espresso.mockCanvas.MockCanvas -import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.Stub +import com.instructure.canvas.espresso.StubCoverage import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.pandautils.utils.Const import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.tokenLogin @@ -91,6 +93,7 @@ class UserFilesInteractionTest : StudentTest() { // Should be able to upload a file from the user's device // Mocks the result from the expected intent, then uploads it. @Test + @StubCoverage("Cannot init FileUploadWorker and OfflineSyncWorker") @TestMetaData(Priority.IMPORTANT, FeatureCategory.FILES, TestCategory.INTERACTION) fun testUpload_deviceFile() { goToFilePicker() @@ -120,6 +123,7 @@ class UserFilesInteractionTest : StudentTest() { // Should be able to upload a file from the camera // Mocks the result from the expected intent, then uploads it. @Test + @StubCoverage("Cannot init FileUploadWorker and OfflineSyncWorker") @TestMetaData(Priority.IMPORTANT, FeatureCategory.FILES, TestCategory.INTERACTION) fun testUpload_fileFromCamera() { @@ -161,6 +165,7 @@ class UserFilesInteractionTest : StudentTest() { // Should be able to upload a file from the user's photo gallery // Mocks the result from the expected intent, then uploads it. @Test + @StubCoverage("Cannot init FileUploadWorker and OfflineSyncWorker") @TestMetaData(Priority.IMPORTANT, FeatureCategory.FILES, TestCategory.INTERACTION) fun testUpload_gallery() { goToFilePicker() @@ -222,6 +227,9 @@ class UserFilesInteractionTest : StudentTest() { // Set up some rudimentary mock data, navigate to the file list page, then // initiate a file upload private fun goToFilePicker() : MockCanvas { + + File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "file_upload").deleteRecursively() + val data = MockCanvas.init( courseCount = 1, favoriteCourseCount = 1, 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 5164267d7f..871d533d39 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 @@ -20,18 +20,24 @@ import android.view.View import android.widget.ScrollView import androidx.appcompat.widget.AppCompatButton import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onData import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers +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.isEnabled import com.instructure.canvas.espresso.CanvasTest import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.stringContainsTextCaseInsensitive import com.instructure.canvas.espresso.waitForMatcherWithSleeps import com.instructure.canvasapi2.models.Assignment +import com.instructure.dataseeding.model.SubmissionType import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertContainsText import com.instructure.espresso.assertDisplayed @@ -57,6 +63,8 @@ import com.instructure.espresso.waitForCheck import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.anything +import org.hamcrest.Matchers.not open class AssignmentDetailsPage : BasePage(R.id.assignmentDetailsPage) { val toolbar by OnViewWithId(R.id.toolbar) @@ -221,6 +229,86 @@ open class AssignmentDetailsPage : BasePage(R.id.assignmentDetailsPage) { waitForView(allOf(withId(R.id.attemptTitle), withAncestor(withId(R.id.attemptSpinner)))).assertDisplayed() waitForView(allOf(withId(R.id.attemptDate), withAncestor(withId(R.id.attemptSpinner)))).assertDisplayed() } + + fun selectSubmissionType(submissionType: SubmissionType) { + val viewMatcher = when (submissionType) { + SubmissionType.ONLINE_TEXT_ENTRY -> withId(R.id.submissionEntryText) + SubmissionType.ONLINE_UPLOAD -> withId(R.id.submissionEntryFile) + SubmissionType.ONLINE_URL -> withId(R.id.submissionEntryWebsite) + SubmissionType.MEDIA_RECORDING -> withId(R.id.submissionEntryMedia) + + else -> {withId(R.id.submissionEntryText)} + } + + onView(viewMatcher).click() + } + + fun assertSubmissionTypeDisplayed(submissionType: String) { + onView(withText(submissionType) + withAncestor(R.id.customPanel)).assertDisplayed() + } + + fun assertReminderSectionNotDisplayed() { + onView(withId(R.id.reminderTitle)).assertNotDisplayed() + onView(withId(R.id.reminderDescription)).assertNotDisplayed() + onView(withId(R.id.reminderAdd)).assertNotDisplayed() + } + + fun assertReminderSectionDisplayed() { + onView(withId(R.id.reminderTitle)).scrollTo().assertDisplayed() + onView(withId(R.id.reminderDescription)).scrollTo().assertDisplayed() + onView(withId(R.id.reminderAdd)).scrollTo().assertDisplayed() + } + + fun clickAddReminder() { + onView(withId(R.id.reminderAdd)).scrollTo().click() + } + + fun selectTimeOption(timeOption: String) { + onView(withText(timeOption)).scrollTo().click() + } + + fun assertReminderDisplayedWithText(text: String) { + onView(withText(text)).scrollTo().assertDisplayed() + } + + fun removeReminderWithText(text: String) { + onView( + allOf( + withId(R.id.remove), + hasSibling(withText(text)) + ) + ).click() + onView(withText(R.string.yes)).scrollTo().click() + } + + fun assertReminderNotDisplayedWithText(text: String) { + onView(withText(text)).check(doesNotExist()) + } + + fun clickCustom() { + onData(anything()).inRoot(isDialog()).atPosition(6).perform(click()) + } + + fun fillQuantity(quantity: String) { + onView(withId(R.id.quantity)).scrollTo().typeText(quantity) + Espresso.closeSoftKeyboard() + } + + fun clickHoursBefore() { + onView(withId(R.id.hours)).scrollTo().click() + } + + fun clickDaysBefore() { + onView(withId(R.id.days)).scrollTo().click() + } + + fun assertDoneButtonIsDisabled() { + onView(withText(R.string.done)).check(matches(not(isEnabled()))) + } + + fun clickDone() { + onView(withText(R.string.done)).click() + } } /** diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CollaborationsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CollaborationsPage.kt index d7dd3b1541..4d40a43b7d 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CollaborationsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CollaborationsPage.kt @@ -47,18 +47,18 @@ object CollaborationsPage { .checkRepeat(webMatches(getText(), containsString("Start a New Collaboration") ), 30) } - fun assertGoogleDocsChoicePresent() { + fun assertGoogleDocsChoicePresentAsDefaultOption() { Web.onWebView(Matchers.allOf(withId(R.id.contentWebView), isDisplayed())) .withElement(DriverAtoms.findElement(Locator.ID, "collaboration_collaboration_type")) .perform(DriverAtoms.webScrollIntoView()) .check(webMatches(getText(), containsString("Google Docs") )) } - fun assertGoogleDocsExplanationPresent() { + fun assertGoogleDocsWarningDescriptionPresent() { Web.onWebView(Matchers.allOf(withId(R.id.contentWebView), isDisplayed())) .withElement(DriverAtoms.findElement(Locator.ID, "google_docs_description")) .perform(DriverAtoms.webScrollIntoView()) - .check(webMatches(getText(), containsString("Google Docs is a great place to collaborate") )) + .check(webMatches(getText(), containsString("Warning:") )) } } \ No newline at end of file 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 18971f74a5..4ede8e10ec 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 @@ -27,7 +27,6 @@ import androidx.test.espresso.ViewAction import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.platform.app.InstrumentationRegistry import com.instructure.canvas.espresso.scrollRecyclerView @@ -43,6 +42,7 @@ import com.instructure.espresso.page.* import com.instructure.student.R import com.instructure.student.ui.utils.ViewUtils import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.CoreMatchers.anyOf import org.hamcrest.CoreMatchers.containsString import org.hamcrest.Matcher import org.hamcrest.Matchers @@ -359,7 +359,7 @@ class DashboardPage : BasePage(R.id.dashboardPage) { } //OfflineMethod - fun waitForNetworkComeBack() { + fun waitForOfflineIndicatorNotDisplayed() { assertDisplaysCourses() retry(times = 5, delay = 2000) { assertOfflineIndicatorNotDisplayed() @@ -367,13 +367,22 @@ class DashboardPage : BasePage(R.id.dashboardPage) { } //OfflineMethod - fun waitForNetworkOff() { + fun waitForOfflineIndicatorDisplayed() { assertDisplaysCourses() retry(times = 5, delay = 2000) { assertOfflineIndicatorDisplayed() } } + //OfflineMethod + fun waitForOfflineSyncDashboardNotifications() { + waitForSyncProgressDownloadStartedNotification() + waitForSyncProgressDownloadStartedNotificationToDisappear() + + waitForSyncProgressStartingNotification() + waitForSyncProgressStartingNotificationToDisappear() + } + //OfflineMethod fun assertCourseOfflineSyncIconVisible(courseName: String) { waitForView(withId(R.id.offlineSyncIcon) + hasSibling(withId(R.id.titleTextView) + withText(courseName))).check(matches(withEffectiveVisibility(Visibility.VISIBLE))) @@ -386,7 +395,8 @@ class DashboardPage : BasePage(R.id.dashboardPage) { //OfflineMethod fun clickOnSyncProgressNotification() { - waitForView(ViewMatchers.withText(com.instructure.pandautils.R.string.syncProgress_syncingOfflineContent)).click() + Thread.sleep(2500) + onView(anyOf(withText(R.string.syncProgress_syncQueued),withText(R.string.syncProgress_downloadStarting), withText(R.string.syncProgress_syncingOfflineContent))).click() } //OfflineMethod 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 364b5ac258..a7655e6675 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 @@ -91,7 +91,7 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { onView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)).scrollTo() } - private fun clickReply() { + fun clickReply() { replyButton.click() } @@ -118,11 +118,15 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { fun sendReply(replyMessage: String) { clickReply() waitForViewWithId(R.id.rce_webView).perform(TypeInRCETextEditor(replyMessage)) - onView(withId(R.id.menu_send)).click() + clickOnSendReplyButton() sleep(3000) // wait out the toast message } + private fun clickOnSendReplyButton() { + onView(withId(R.id.menu_send)).click() + } + fun assertReplyDisplayed(reply: DiscussionEntry, refreshesAllowed: Int = 0) { // Allow up to refreshesAllowed attempt/refresh cycles 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 1a5b4edd07..786d1e2406 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 @@ -20,9 +20,7 @@ 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 -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.scrollRecyclerView @@ -58,6 +56,11 @@ class FileListPage(val searchable: Searchable) : BasePage(R.id.fileListPage) { waitForView(matcher).scrollTo().assertDisplayed() } + fun assertSearchItemDisplayed(itemName: String) { + val matcher = allOf(withId(R.id.fileName), withAncestor(R.id.fileSearchRecyclerView), withText(itemName)) + waitForView(matcher).scrollTo().assertDisplayed() + } + fun assertItemNotDisplayed(itemName: String) { val matcher = allOf(withId(R.id.fileName), withText(itemName)) onView(matcher).assertNotDisplayed() @@ -127,14 +130,14 @@ class FileListPage(val searchable: Searchable) : BasePage(R.id.fileListPage) { fun assertSearchResultCount(expectedCount: Int) { Thread.sleep(2000) onView(withId(R.id.fileSearchRecyclerView) + withAncestor(R.id.container)).check( - ViewAssertions.matches(ViewMatchers.hasChildCount(expectedCount)) + matches(hasChildCount(expectedCount)) ) } fun assertFileListCount(expectedCount: Int) { Thread.sleep(2000) - onView(withId(R.id.listView) + withAncestor(R.id.container)).check( - ViewAssertions.matches(ViewMatchers.hasChildCount(expectedCount)) + onView(withId(R.id.listView) + withAncestor(R.id.fileListPage)).check( + matches(hasChildCount(expectedCount)) ) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileUploadPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileUploadPage.kt index 158d895e8b..752a4875cc 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileUploadPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileUploadPage.kt @@ -16,11 +16,9 @@ */ package com.instructure.student.ui.pages -import android.widget.Button 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.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.withId import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.espresso.OnViewWithId @@ -34,7 +32,6 @@ import com.instructure.espresso.page.withDescendant import com.instructure.espresso.page.withText import com.instructure.espresso.scrollTo import com.instructure.student.R -import org.hamcrest.core.AllOf.allOf class FileUploadPage : BasePage() { private val cameraButton by OnViewWithId(R.id.fromCamera) @@ -56,7 +53,7 @@ class FileUploadPage : BasePage() { } fun clickUpload() { - onView(allOf(isAssignableFrom(Button::class.java), withText(R.string.upload))).click() + onView(withText(R.string.upload)).click() } fun clickTurnIn() { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/SyncProgressPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/SyncProgressPage.kt index f49c368035..dfb0cd3307 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/SyncProgressPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/SyncProgressPage.kt @@ -17,12 +17,15 @@ package com.instructure.student.ui.pages.offline +import android.widget.TextView import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasSibling import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.assertContainsText import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertVisibility +import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.plus @@ -32,6 +35,7 @@ import com.instructure.espresso.page.withId import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText import com.instructure.pandautils.R +import com.instructure.student.ui.utils.getView class SyncProgressPage : BasePage(R.id.syncProgressPage) { @@ -55,4 +59,32 @@ class SyncProgressPage : BasePage(R.id.syncProgressPage) { onView(withId(R.id.courseName) + withText(courseName) + withAncestor(R.id.syncProgressPage)).assertDisplayed() onView(withId(R.id.successIndicator) + withParent(withId(R.id.actionContainer) + hasSibling(withId(R.id.courseName) + withText(courseName)))).assertVisibility(ViewMatchers.Visibility.VISIBLE) } + + fun expandCollapseCourse(courseName: String) { + onView(withId(R.id.toggleButton) + hasSibling(withId(R.id.courseName) + withText(courseName))).click() + } + + fun assertCourseTabSynced(tabName: String) { + onView(withId(R.id.successIndicator) + withParent(withId(R.id.actionContainer) + hasSibling(withId(R.id.tabTitle) + withText(tabName)))).assertVisibility(ViewMatchers.Visibility.VISIBLE) + } + + fun getCourseSize(courseName: String): Int { + val courseSizeView = onView(withId(R.id.courseSize) + hasSibling(withId(R.id.courseName) + withText(courseName))) + val courseSizeText = (courseSizeView.getView() as TextView).text.toString() + return courseSizeText.split(" ")[0].toInt() + } + + fun assertSumOfCourseSizes(expectedSize: Int) { + if(expectedSize > 999) { + val convertedSumSize = convertKiloBytesToMegaBytes(expectedSize) + onView(withId(R.id.downloadProgressText)).assertContainsText(convertedSumSize.toString()) + } + else { + onView(withId(R.id.downloadProgressText)).assertContainsText(expectedSize.toString()) + } + } + + private fun convertKiloBytesToMegaBytes(kilobytes: Int): Double { + return kilobytes / 1000.0 + } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt new file mode 100644 index 0000000000..078ec98f70 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 - 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.utils + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.instructure.student.activity.LoginActivity +import org.junit.Rule + +abstract class StudentComposeTest : StudentTest() { + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() +} \ 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 d11f313b76..e72cd5bd6b 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 @@ -32,6 +32,7 @@ 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 androidx.test.uiautomator.UiDevice import com.instructure.canvas.espresso.CanvasTest import com.instructure.espresso.InstructureActivityTestRule import com.instructure.espresso.Searchable @@ -112,6 +113,8 @@ import javax.inject.Inject abstract class StudentTest : CanvasTest() { + val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + override val activityRule: InstructureActivityTestRule = StudentActivityTestRule(LoginActivity::class.java) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt index 991c0e1778..3fc55b3592 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt @@ -58,7 +58,7 @@ fun StudentTest.slowLogIn(enrollmentType: String = EnrollmentTypes.STUDENT_ENROL return user } -fun StudentTest.seedDataForK5( +fun seedDataForK5( teachers: Int = 0, tas: Int = 0, pastCourses: Int = 0, @@ -69,7 +69,8 @@ fun StudentTest.seedDataForK5( announcements: Int = 0, discussions: Int = 0, syllabusBody: String? = null, - gradingPeriods: Boolean = false): SeedApi.SeededDataApiModel { + gradingPeriods: Boolean = false +): SeedApi.SeededDataApiModel { val request = SeedApi.SeedDataRequest ( teachers = teachers, @@ -88,7 +89,7 @@ fun StudentTest.seedDataForK5( return SeedApi.seedDataForSubAccount(request) } -fun StudentTest.seedData( +fun seedData( teachers: Int = 0, tas: Int = 0, pastCourses: Int = 0, @@ -100,7 +101,8 @@ fun StudentTest.seedData( locked: Boolean = false, discussions: Int = 0, syllabusBody: String? = null, - gradingPeriods: Boolean = false): SeedApi.SeededDataApiModel { + gradingPeriods: Boolean = false +): SeedApi.SeededDataApiModel { val request = SeedApi.SeedDataRequest ( teachers = teachers, @@ -119,15 +121,16 @@ fun StudentTest.seedData( return SeedApi.seedData(request) } -fun StudentTest.seedAssignments( - courseId: Long, - assignments: Int = 1, - withDescription: Boolean = false, - lockAt: String = "", - unlockAt: String = "", - dueAt: String = "", - submissionTypes: List = emptyList(), - teacherToken: String): List { +fun seedAssignments( + courseId: Long, + assignments: Int = 1, + withDescription: Boolean = false, + lockAt: String = "", + unlockAt: String = "", + dueAt: String = "", + submissionTypes: List = emptyList(), + teacherToken: String +): List { return AssignmentsApi.seedAssignments(AssignmentsApi.CreateAssignmentRequest( courseId = courseId, @@ -203,7 +206,7 @@ fun StudentTest.tokenLoginElementary(user: CanvasUserApiModel) { elementaryDashboardPage.assertPageObjects() } -fun StudentTest.routeTo(route: String) { +fun routeTo(route: String) { val url = "canvas-student://${CanvasNetworkAdapter.canvasDomain}/$route" val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -213,7 +216,7 @@ fun StudentTest.routeTo(route: String) { context.startActivity(intent) } -fun StudentTest.routeTo(route: String, domain: String) { +fun routeTo(route: String, domain: String) { val url = "canvas-student://$domain/$route" val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -223,39 +226,41 @@ fun StudentTest.routeTo(route: String, domain: String) { context.startActivity(intent) } -fun StudentTest.routeTo(route: Route, activity: FragmentActivity) { +fun routeTo(route: Route, activity: FragmentActivity) { RouteMatcher.route(activity, route) } -fun StudentTest.seedAssignmentSubmission( - submissionSeeds: List, - assignmentId: Long, - courseId: Long, - studentToken: String, - commentSeeds: List = kotlin.collections.emptyList() +fun seedAssignmentSubmission( + submissionSeeds: List, + assignmentId: Long, + courseId: Long, + studentToken: String, + commentSeeds: List = emptyList() ): List { // Upload one submission file for each submission seed submissionSeeds.forEach { it.attachmentsList.add( when (it.submissionType) { - SubmissionType.ONLINE_UPLOAD -> uploadTextFile(courseId, assignmentId, studentToken, + SubmissionType.ONLINE_UPLOAD -> uploadTextFile( + courseId, assignmentId, studentToken, FileUploadType.ASSIGNMENT_SUBMISSION ) else -> AttachmentApiModel(displayName="", fileName="", id=0L) // Not handled right now } - ); + ) } // Add attachments to comment seeds commentSeeds.forEach { - val fileAttachments: MutableList = kotlin.collections.mutableListOf() + val fileAttachments: MutableList = mutableListOf() for (i in 0..it.amount) { if (it.fileType != FileType.NONE) { fileAttachments.add(when (it.fileType) { - FileType.PDF -> kotlin.TODO() - FileType.TEXT -> uploadTextFile(courseId, assignmentId, studentToken, + FileType.PDF -> TODO() + FileType.TEXT -> uploadTextFile( + courseId, assignmentId, studentToken, FileUploadType.COMMENT_ATTACHMENT ) else -> throw RuntimeException("Unknown file type passed into StudentTest.seedAssignmentSubmission") // Unknown type @@ -266,19 +271,15 @@ fun StudentTest.seedAssignmentSubmission( it.attachmentsList.addAll(fileAttachments) } - // Seed the submissions - val submissionRequest = SubmissionsApi.SubmissionSeedRequest( - assignmentId = assignmentId, - courseId = courseId, - studentToken = studentToken, - commentSeedsList = commentSeeds, - submissionSeedsList = submissionSeeds - ) - - return SubmissionsApi.seedAssignmentSubmission(submissionRequest) + return SubmissionsApi.seedAssignmentSubmission(courseId, studentToken, assignmentId, commentSeeds, submissionSeeds) } -fun StudentTest.uploadTextFile(courseId: Long, assignmentId: Long, token: String, fileUploadType: FileUploadType): AttachmentApiModel { +fun uploadTextFile( + courseId: Long, + assignmentId: Long? = null, + token: String, + fileUploadType: FileUploadType +): AttachmentApiModel { // Create the file val file = File( @@ -294,10 +295,11 @@ fun StudentTest.uploadTextFile(courseId: Long, assignmentId: Long, token: String // Start the Canvas file upload process return FileUploadsApi.uploadFile( - courseId, - assignmentId, - file.readBytes(), - file.name, - token, - fileUploadType) + courseId, + assignmentId, + file.readBytes(), + file.name, + token, + fileUploadType + ) } diff --git a/apps/student/src/main/AndroidManifest.xml b/apps/student/src/main/AndroidManifest.xml index 064bc4dabb..121b471855 100644 --- a/apps/student/src/main/AndroidManifest.xml +++ b/apps/student/src/main/AndroidManifest.xml @@ -40,6 +40,7 @@ + - + @@ -296,6 +298,11 @@ + + + { @@ -214,7 +270,8 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. StudentLogoutTask( LogoutTask.Type.LOGOUT, typefaceBehavior = typefaceBehavior, - databaseProvider = databaseProvider + databaseProvider = databaseProvider, + alarmScheduler = alarmScheduler ).execute() } .setNegativeButton(android.R.string.cancel, null) @@ -325,6 +382,8 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. } catch { firebaseCrashlytics.recordException(it) } + + scheduleAlarms() } private fun handleTokenCheck(online: Boolean?) { @@ -334,7 +393,12 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. lifecycleScope.launch { val isTokenValid = repository.isTokenValid() if (!isTokenValid) { - StudentLogoutTask(LogoutTask.Type.LOGOUT, typefaceBehavior = typefaceBehavior, databaseProvider = databaseProvider).execute() + StudentLogoutTask( + LogoutTask.Type.LOGOUT, + typefaceBehavior = typefaceBehavior, + databaseProvider = databaseProvider, + alarmScheduler = alarmScheduler + ).execute() } } } @@ -387,7 +451,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. if (ApiPrefs.user == null ) { // Hard case to repro but it's possible for a user to force exit the app before we finish saving the user but they will still launch into the app // If that happens, log out - StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider).execute() + StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider, alarmScheduler = alarmScheduler).execute() } setupBottomNavigation() @@ -664,7 +728,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. override fun overrideFont() { super.overrideFont() - typefaceBehavior.overrideFont(navigationBehavior.fontFamily.fontPath) + typefaceBehavior.overrideFont(navigationBehavior.canvasFont) } //endregion @@ -894,6 +958,8 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. private fun selectBottomNavFragment(fragmentClass: Class) { val selectedFragment = supportFragmentManager.findFragmentByTag(fragmentClass.name) + (topFragment as? DashboardFragment)?.cancelCardDrag() + if (selectedFragment == null) { val fragment = createBottomNavFragment(fragmentClass.name) val newArguments = if (fragment?.arguments != null) fragment.requireArguments() else Bundle() @@ -1215,6 +1281,12 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. } } + private fun scheduleAlarms() { + lifecycleScope.launch { + alarmScheduler.scheduleAllAlarmsForCurrentUser() + } + } + companion object { fun createIntent(context: Context, route: Route): Intent { return Intent(context, NavigationActivity::class.java).apply { putExtra(Route.ROUTE, route) } diff --git a/apps/student/src/main/java/com/instructure/student/di/AlarmSchedulerModule.kt b/apps/student/src/main/java/com/instructure/student/di/AlarmSchedulerModule.kt new file mode 100644 index 0000000000..ee3ad2f563 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/di/AlarmSchedulerModule.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 - 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.di + +import android.content.Context +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.student.features.assignments.reminder.AlarmScheduler +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class AlarmSchedulerModule { + + @Provides + fun provideAlarmScheduler(@ApplicationContext context: Context, reminderDao: ReminderDao, apiPrefs: ApiPrefs): AlarmScheduler { + return AlarmScheduler(context, reminderDao, apiPrefs) + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt b/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt index 24914b2cd9..5672e1660e 100644 --- a/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt +++ b/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt @@ -20,6 +20,7 @@ import androidx.fragment.app.FragmentActivity import com.instructure.loginapi.login.LoginNavigation import com.instructure.loginapi.login.features.acceptableusepolicy.AcceptableUsePolicyRouter import com.instructure.pandautils.room.offline.DatabaseProvider +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.features.login.StudentAcceptableUsePolicyRouter import com.instructure.student.features.login.StudentLoginNavigation import dagger.Module @@ -32,12 +33,16 @@ import dagger.hilt.android.components.ActivityComponent class LoginModule { @Provides - fun provideAcceptabelUsePolicyRouter(activity: FragmentActivity, databaseProvider: DatabaseProvider): AcceptableUsePolicyRouter { - return StudentAcceptableUsePolicyRouter(activity, databaseProvider) + fun provideAcceptabelUsePolicyRouter( + activity: FragmentActivity, + databaseProvider: DatabaseProvider, + alarmScheduler: AlarmScheduler + ): AcceptableUsePolicyRouter { + return StudentAcceptableUsePolicyRouter(activity, databaseProvider, alarmScheduler) } @Provides - fun provideLoginNavigation(activity: FragmentActivity, databaseProvider: DatabaseProvider): LoginNavigation { - return StudentLoginNavigation(activity, databaseProvider) + fun provideLoginNavigation(activity: FragmentActivity, databaseProvider: DatabaseProvider, alarmScheduler: AlarmScheduler): LoginNavigation { + return StudentLoginNavigation(activity, databaseProvider, alarmScheduler) } } \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/di/feature/AssignmentDetailsModule.kt b/apps/student/src/main/java/com/instructure/student/di/feature/AssignmentDetailsModule.kt index 05e0815954..36ec1daf56 100644 --- a/apps/student/src/main/java/com/instructure/student/di/feature/AssignmentDetailsModule.kt +++ b/apps/student/src/main/java/com/instructure/student/di/feature/AssignmentDetailsModule.kt @@ -18,6 +18,7 @@ package com.instructure.student.di.feature import com.instructure.canvasapi2.apis.* +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao import com.instructure.pandautils.room.offline.daos.QuizDao import com.instructure.pandautils.room.offline.facade.AssignmentFacade import com.instructure.pandautils.room.offline.facade.CourseFacade @@ -59,8 +60,9 @@ class AssignmentDetailsModule { networkStateProvider: NetworkStateProvider, localDataSource: AssignmentDetailsLocalDataSource, networkDataSource: AssignmentDetailsNetworkDataSource, - featureFlagProvider: FeatureFlagProvider + featureFlagProvider: FeatureFlagProvider, + reminderDao: ReminderDao ): AssignmentDetailsRepository { - return AssignmentDetailsRepository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider) + return AssignmentDetailsRepository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider, reminderDao) } } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt index 5dc83c242f..d9cf249358 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt @@ -17,9 +17,14 @@ package com.instructure.student.features.assignments.details +import android.app.AlarmManager import android.app.Dialog +import android.content.Context +import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.Settings import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -27,10 +32,17 @@ import android.webkit.WebView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.fragment.app.viewModels +import com.google.android.material.snackbar.Snackbar import com.instructure.canvasapi2.CanvasRestAdapter -import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Assignment.SubmissionType -import com.instructure.canvasapi2.utils.* +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.LTITool +import com.instructure.canvasapi2.models.RemoteFile +import com.instructure.canvasapi2.utils.Analytics +import com.instructure.canvasapi2.utils.AnalyticsEventConstants +import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.pageview.PageView import com.instructure.canvasapi2.utils.pageview.PageViewUrlParam import com.instructure.interactions.bookmarks.Bookmarkable @@ -41,7 +53,18 @@ import com.instructure.pandautils.analytics.SCREEN_VIEW_ASSIGNMENT_DETAILS import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.features.discussion.router.DiscussionRouterFragment import com.instructure.pandautils.features.shareextension.ShareFileSubmissionTarget -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.LongArg +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.PermissionUtils +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.makeBundle +import com.instructure.pandautils.utils.orDefault +import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.setupAsBackButton +import com.instructure.pandautils.utils.showThemed +import com.instructure.pandautils.utils.toast +import com.instructure.pandautils.utils.withArgs import com.instructure.pandautils.views.CanvasWebView import com.instructure.pandautils.views.RecordingMediaType import com.instructure.student.R @@ -49,7 +72,12 @@ import com.instructure.student.activity.BaseRouterActivity import com.instructure.student.databinding.DialogSubmissionPickerBinding import com.instructure.student.databinding.DialogSubmissionPickerMediaBinding import com.instructure.student.databinding.FragmentAssignmentDetailsBinding -import com.instructure.student.fragment.* +import com.instructure.student.features.assignments.reminder.CustomReminderDialog +import com.instructure.student.fragment.BasicQuizViewFragment +import com.instructure.student.fragment.InternalWebviewFragment +import com.instructure.student.fragment.LtiLaunchFragment +import com.instructure.student.fragment.ParentFragment +import com.instructure.student.fragment.StudioWebViewFragment import com.instructure.student.mobius.assignmentDetails.getVideoUri import com.instructure.student.mobius.assignmentDetails.launchAudio import com.instructure.student.mobius.assignmentDetails.needsPermissions @@ -134,6 +162,11 @@ class AssignmentDetailsFragment : ParentFragment(), Bookmarkable { } } + override fun onResume() { + super.onResume() + checkAlarmPermissionResult() + } + private fun handleAction(action: AssignmentDetailAction) { val canvasContext = canvasContext as? CanvasContext ?: run { toast(R.string.generalUnexpectedError) @@ -194,6 +227,15 @@ class AssignmentDetailsFragment : ParentFragment(), Bookmarkable { is AssignmentDetailAction.OnDiscussionHeaderAttachmentClicked -> { showDiscussionAttachments(action.attachments) } + is AssignmentDetailAction.ShowReminderDialog -> { + checkAlarmPermission() + } + is AssignmentDetailAction.ShowCustomReminderDialog -> { + showCustomReminderDialog() + } + is AssignmentDetailAction.ShowDeleteReminderConfirmationDialog -> { + showDeleteReminderConfirmationDialog(action.onConfirmed) + } } } @@ -387,6 +429,80 @@ class AssignmentDetailsFragment : ParentFragment(), Bookmarkable { ) } + private fun checkAlarmPermission() { + val alarmManager = context?.getSystemService(Context.ALARM_SERVICE) as AlarmManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (alarmManager.canScheduleExactAlarms()) { + showCreateReminderDialog() + } else { + viewModel.checkingReminderPermission = true + startActivity( + Intent( + Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, + Uri.parse("package:" + requireContext().packageName) + ) + ) + } + } else { + showCreateReminderDialog() + } + } + + private fun checkAlarmPermissionResult() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && viewModel.checkingReminderPermission) { + if ((context?.getSystemService(Context.ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms()) { + showCreateReminderDialog() + } else { + Snackbar.make(requireView(), getString(R.string.reminderPermissionNotGrantedError), Snackbar.LENGTH_LONG).show() + } + } + } + + private fun showCreateReminderDialog() { + val choices = listOf( + ReminderChoice.Minute(5), + ReminderChoice.Minute(15), + ReminderChoice.Minute(30), + ReminderChoice.Hour(1), + ReminderChoice.Day(1), + ReminderChoice.Week(1), + ReminderChoice.Custom, + ) + + AlertDialog.Builder(requireContext()) + .setTitle(R.string.reminderTitle) + .setNegativeButton(R.string.cancel, null) + .setSingleChoiceItems( + choices.map { + if (it is ReminderChoice.Custom) { + it.getText(resources) + } else { + getString(R.string.reminderBefore, it.getText(resources)) + } + }.toTypedArray(), -1 + ) { dialog, which -> + viewModel.onReminderSelected(choices[which]) + dialog.dismiss() + } + .showThemed() + } + + private fun showCustomReminderDialog() { + CustomReminderDialog.newInstance().show(childFragmentManager, null) + } + + private fun showDeleteReminderConfirmationDialog(onConfirmed: () -> Unit) { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.deleteReminderTitle) + .setMessage(R.string.deleteReminderMessage) + .setNegativeButton(R.string.no, null) + .setPositiveButton(R.string.yes) { dialog, _ -> + onConfirmed() + dialog.dismiss() + } + .showThemed() + } + companion object { fun makeRoute(course: CanvasContext, assignmentId: Long): Route { val bundle = course.makeBundle { putLong(Const.ASSIGNMENT_ID, assignmentId) } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt index 0a7d26035d..475df251da 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt @@ -17,11 +17,14 @@ package com.instructure.student.features.assignments.details +import androidx.lifecycle.LiveData import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.LTITool import com.instructure.canvasapi2.models.Quiz import com.instructure.pandautils.repository.Repository +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.student.features.assignments.details.datasource.AssignmentDetailsDataSource @@ -32,7 +35,8 @@ class AssignmentDetailsRepository( localDataSource: AssignmentDetailsLocalDataSource, networkDataSource: AssignmentDetailsNetworkDataSource, networkStateProvider: NetworkStateProvider, - featureFlagProvider: FeatureFlagProvider + featureFlagProvider: FeatureFlagProvider, + private val reminderDao: ReminderDao ) : Repository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider) { suspend fun getCourseWithGrade(courseId: Long, forceNetwork: Boolean): Course { @@ -54,4 +58,23 @@ class AssignmentDetailsRepository( suspend fun getLtiFromAuthenticationUrl(url: String, forceNetwork: Boolean): LTITool? { return dataSource().getLtiFromAuthenticationUrl(url, forceNetwork) } + + fun getRemindersByAssignmentIdLiveData(userId: Long, assignmentId: Long): LiveData> { + return reminderDao.findByAssignmentIdLiveData(userId, assignmentId) + } + + suspend fun deleteReminderById(id: Long) { + reminderDao.deleteById(id) + } + + suspend fun addReminder(userId: Long, assignment: Assignment, text: String, time: Long) = reminderDao.insert( + ReminderEntity( + userId = userId, + assignmentId = assignment.id, + htmlUrl = assignment.htmlUrl.orEmpty(), + name = assignment.name.orEmpty(), + text = text, + time = time + ) + ) } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt index 5058b054a4..9327ac087a 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt @@ -1,13 +1,20 @@ package com.instructure.student.features.assignments.details +import android.content.res.Resources import android.text.Spanned import androidx.annotation.ColorRes import androidx.databinding.BaseObservable import androidx.databinding.Bindable -import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.LTITool +import com.instructure.canvasapi2.models.Quiz +import com.instructure.canvasapi2.models.RemoteFile import com.instructure.pandautils.features.assignmentdetails.AssignmentDetailsAttemptItemViewModel import com.instructure.pandautils.utils.ThemedColor +import com.instructure.student.R import com.instructure.student.features.assignments.details.gradecellview.GradeCellViewData +import com.instructure.student.features.assignments.details.itemviewmodels.ReminderItemViewModel data class AssignmentDetailsViewData( val courseColor: ThemedColor, @@ -32,7 +39,9 @@ data class AssignmentDetailsViewData( val discussionHeaderViewData: DiscussionHeaderViewData? = null, val quizDetails: QuizViewViewData? = null, val attemptsViewData: AttemptsViewData? = null, - @Bindable var hasDraft: Boolean = false + @Bindable var hasDraft: Boolean = false, + val showReminders: Boolean = false, + @Bindable var reminders: List = emptyList() ) : BaseObservable() { val firstAttemptOrNull = attempts.firstOrNull() val noDescriptionVisible = description.isEmpty() && !fullLocked @@ -51,6 +60,32 @@ data class DiscussionHeaderViewData( val onAttachmentClicked: () -> Unit ) +data class ReminderViewData(val id: Long, val text: String) + +sealed class ReminderChoice { + data class Minute(val quantity: Int) : ReminderChoice() + data class Hour(val quantity: Int) : ReminderChoice() + data class Day(val quantity: Int) : ReminderChoice() + data class Week(val quantity: Int) : ReminderChoice() + data object Custom : ReminderChoice() + + fun getText(resources: Resources) = when (this) { + is Minute -> resources.getQuantityString(R.plurals.reminderMinute, quantity, quantity) + is Hour -> resources.getQuantityString(R.plurals.reminderHour, quantity, quantity) + is Day -> resources.getQuantityString(R.plurals.reminderDay, quantity, quantity) + is Week -> resources.getQuantityString(R.plurals.reminderWeek, quantity, quantity) + is Custom -> resources.getString(R.string.reminderCustom) + } + + fun getTimeInMillis() = when (this) { + is Minute -> quantity * 60 * 1000L + is Hour -> quantity * 60 * 60 * 1000L + is Day -> quantity * 24 * 60 * 60 * 1000L + is Week -> quantity * 7 * 24 * 60 * 60 * 1000L + else -> 0 + } +} + sealed class AssignmentDetailAction { data class ShowToast(val message: String) : AssignmentDetailAction() data class NavigateToLtiScreen(val url: String) : AssignmentDetailAction() @@ -76,4 +111,7 @@ sealed class AssignmentDetailAction { data class ShowSubmitDialog(val assignment: Assignment, val studioLTITool: LTITool?) : AssignmentDetailAction() data class NavigateToUploadStatusScreen(val submissionId: Long) : AssignmentDetailAction() data class OnDiscussionHeaderAttachmentClicked(val attachments: List) : AssignmentDetailAction() + data object ShowReminderDialog : AssignmentDetailAction() + data object ShowCustomReminderDialog : AssignmentDetailAction() + data class ShowDeleteReminderConfirmationDialog(val onConfirmed: () -> Unit) : AssignmentDetailAction() } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt index 487d856402..00ea9846cc 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt @@ -22,6 +22,7 @@ import android.content.Context import android.content.res.Resources import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -44,6 +45,7 @@ import com.instructure.pandautils.features.assignmentdetails.AssignmentDetailsAt import com.instructure.pandautils.features.assignmentdetails.AssignmentDetailsAttemptViewData import com.instructure.pandautils.mvvm.Event import com.instructure.pandautils.mvvm.ViewState +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity import com.instructure.pandautils.utils.AssignmentUtils2 import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Const @@ -52,6 +54,8 @@ import com.instructure.pandautils.utils.orDefault import com.instructure.student.R import com.instructure.student.db.StudentDb import com.instructure.student.features.assignments.details.gradecellview.GradeCellViewData +import com.instructure.student.features.assignments.details.itemviewmodels.ReminderItemViewModel +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.mobius.assignmentDetails.getFormattedAttemptDate import com.instructure.student.mobius.assignmentDetails.uploadAudioRecording import com.instructure.student.util.getStudioLTITool @@ -73,7 +77,8 @@ class AssignmentDetailsViewModel @Inject constructor( private val htmlContentFormatter: HtmlContentFormatter, private val colorKeeper: ColorKeeper, private val application: Application, - apiPrefs: ApiPrefs, + private val apiPrefs: ApiPrefs, + private val alarmScheduler: AlarmScheduler, database: StudentDb ) : ViewModel(), Query.Listener { @@ -112,6 +117,19 @@ class AssignmentDetailsViewModel @Inject constructor( private val submissionQuery = database.submissionQueries.getSubmissionsByAssignmentId(assignmentId, apiPrefs.user?.id.orDefault()) + private val remindersObserver = Observer> { + _data.value?.reminders = mapReminders(it) + _data.value?.notifyPropertyChanged(BR.reminders) + } + + private val remindersLiveData = assignmentDetailsRepository.getRemindersByAssignmentIdLiveData( + apiPrefs.user?.id.orDefault(), assignmentId + ).apply { + observeForever(remindersObserver) + } + + var checkingReminderPermission = false + init { markSubmissionAsRead() submissionQuery.addListener(this) @@ -119,6 +137,11 @@ class AssignmentDetailsViewModel @Inject constructor( loadData() } + override fun onCleared() { + super.onCleared() + remindersLiveData.removeObserver(remindersObserver) + } + override fun queryResultsChanged() { viewModelScope.launch { val submission = submissionQuery.executeAsList().lastOrNull() @@ -244,7 +267,6 @@ class AssignmentDetailsViewModel @Inject constructor( } } - @Suppress("DEPRECATION") private suspend fun getViewData(assignment: Assignment, hasDraft: Boolean): AssignmentDetailsViewData { val points = if (restrictQuantitativeData) { "" @@ -461,7 +483,9 @@ class AssignmentDetailsViewModel @Inject constructor( discussionHeaderViewData = discussionHeaderViewData, quizDetails = quizViewViewData, attemptsViewData = attemptsViewData, - hasDraft = hasDraft + hasDraft = hasDraft, + showReminders = assignment.dueDate?.after(Date()).orDefault(), + reminders = mapReminders(remindersLiveData.value.orEmpty()) ) } @@ -469,6 +493,21 @@ class AssignmentDetailsViewModel @Inject constructor( _events.postValue(Event(action)) } + private fun mapReminders(reminders: List) = reminders.map { + ReminderItemViewModel(ReminderViewData(it.id, resources.getString(R.string.reminderBefore, it.text))) { + postAction(AssignmentDetailAction.ShowDeleteReminderConfirmationDialog { + deleteReminderById(it) + }) + } + } + + private fun deleteReminderById(id: Long) { + alarmScheduler.cancelAlarm(id) + viewModelScope.launch { + assignmentDetailsRepository.deleteReminderById(id) + } + } + fun refresh() { _state.postValue(ViewState.Refresh) loadData(true) @@ -574,4 +613,55 @@ class AssignmentDetailsViewModel @Inject constructor( fun showContent(viewState: ViewState?): Boolean { return (viewState == ViewState.Success || viewState == ViewState.Refresh) && assignment != null } + + fun onAddReminderClicked() { + postAction(AssignmentDetailAction.ShowReminderDialog) + } + + fun onReminderSelected(reminderChoice: ReminderChoice) { + if (reminderChoice == ReminderChoice.Custom) { + postAction(AssignmentDetailAction.ShowCustomReminderDialog) + } else { + setReminder(reminderChoice) + } + } + + private fun setReminder(reminderChoice: ReminderChoice) { + val assignment = assignment ?: return + val alarmTimeInMillis = getAlarmTimeInMillis(reminderChoice) ?: return + val reminderText = reminderChoice.getText(resources) + + if (alarmTimeInMillis < System.currentTimeMillis()) { + postAction(AssignmentDetailAction.ShowToast(resources.getString(R.string.reminderInPast))) + return + } + + if (remindersLiveData.value?.any { it.time == alarmTimeInMillis }.orDefault()) { + postAction(AssignmentDetailAction.ShowToast(resources.getString(R.string.reminderAlreadySet))) + return + } + + viewModelScope.launch { + val reminderId = assignmentDetailsRepository.addReminder( + apiPrefs.user?.id.orDefault(), + assignment, + reminderText, + alarmTimeInMillis + ) + + alarmScheduler.scheduleAlarm( + assignment.id, + assignment.htmlUrl.orEmpty(), + assignment.name.orEmpty(), + reminderText, + alarmTimeInMillis, + reminderId + ) + } + } + + private fun getAlarmTimeInMillis(reminderChoice: ReminderChoice): Long? { + val dueDate = assignment?.dueDate?.time ?: return null + return dueDate - reminderChoice.getTimeInMillis() + } } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/itemviewmodels/ReminderItemViewModel.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/itemviewmodels/ReminderItemViewModel.kt new file mode 100644 index 0000000000..bbd443a22c --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/itemviewmodels/ReminderItemViewModel.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 - 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.features.assignments.details.itemviewmodels + +import com.instructure.pandautils.mvvm.ItemViewModel +import com.instructure.student.R +import com.instructure.student.features.assignments.details.ReminderViewData + +class ReminderItemViewModel( + val data: ReminderViewData, + val onRemoveClick: (Long) -> Unit +) : ItemViewModel { + override val layoutId: Int + get() = R.layout.view_reminder +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/AlarmScheduler.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/AlarmScheduler.kt new file mode 100644 index 0000000000..1243b77cc5 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/AlarmScheduler.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2024 - 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.features.assignments.reminder + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.student.receivers.AlarmReceiver + +class AlarmScheduler(private val context: Context, private val reminderDao: ReminderDao, private val apiPrefs: ApiPrefs) { + + private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + + fun scheduleAlarm(assignmentId: Long, assignmentPath: String, assignmentName: String, dueIn: String, timeInMillis: Long, reminderId: Long) { + val intent = Intent(context, AlarmReceiver::class.java) + intent.putExtra(AlarmReceiver.ASSIGNMENT_ID, assignmentId) + intent.putExtra(AlarmReceiver.ASSIGNMENT_PATH, assignmentPath) + intent.putExtra(AlarmReceiver.ASSIGNMENT_NAME, assignmentName) + intent.putExtra(AlarmReceiver.DUE_IN, dueIn) + + val pendingIntent = PendingIntent.getBroadcast( + context, + reminderId.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) return + + alarmManager.setExact(AlarmManager.RTC_WAKEUP, timeInMillis, pendingIntent) + } + + suspend fun scheduleAllAlarmsForCurrentUser() { + val reminders = reminderDao.findByUserId(apiPrefs.user?.id ?: return) + reminders.forEach { + scheduleAlarm(it.assignmentId, it.htmlUrl, it.name, it.text, it.time, it.id) + } + } + + fun cancelAlarm(reminderId: Long) { + val intent = Intent(context, AlarmReceiver::class.java) + + val pendingIntent = PendingIntent.getBroadcast( + context, + reminderId.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + alarmManager.cancel(pendingIntent) + } + + suspend fun cancelAllAlarmsForCurrentUser() { + val reminders = reminderDao.findByUserId(apiPrefs.user?.id ?: return) + reminders.forEach { + cancelAlarm(it.id) + } + } +} diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/CustomReminderDialog.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/CustomReminderDialog.kt new file mode 100644 index 0000000000..311829ba1f --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/CustomReminderDialog.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2024 - 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.features.assignments.reminder + +import android.app.Dialog +import android.content.res.ColorStateList +import android.os.Bundle +import android.widget.Button +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.student.R +import com.instructure.student.databinding.DialogCustomReminderBinding +import com.instructure.student.features.assignments.details.AssignmentDetailsViewModel +import com.instructure.student.features.assignments.details.ReminderChoice +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class CustomReminderDialog : DialogFragment() { + + private lateinit var binding: DialogCustomReminderBinding + private val parentViewModel: AssignmentDetailsViewModel by viewModels(ownerProducer = { + requireParentFragment() + }) + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogCustomReminderBinding.inflate(layoutInflater, null, false) + + return AlertDialog.Builder(requireContext()) + .setView(binding.root) + .setTitle(R.string.customReminderTitle) + .setPositiveButton(R.string.done) { _, _ -> + val quantity = binding.quantity.text.toString().toIntOrNull() ?: return@setPositiveButton + when (binding.choices.checkedRadioButtonId) { + R.id.minutes -> parentViewModel.onReminderSelected(ReminderChoice.Minute(quantity)) + R.id.hours -> parentViewModel.onReminderSelected(ReminderChoice.Hour(quantity)) + R.id.days -> parentViewModel.onReminderSelected(ReminderChoice.Day(quantity)) + R.id.weeks -> parentViewModel.onReminderSelected(ReminderChoice.Week(quantity)) + } + } + .setNegativeButton(R.string.cancel, null) + .create().apply { + setOnShowListener { + getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ThemePrefs.textButtonColor) + setupPositiveButton(getButton(AlertDialog.BUTTON_POSITIVE)) + } + } + } + + private fun setupPositiveButton(button: Button) { + button.isEnabled = false + button.setTextColor( + ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()), + intArrayOf(requireContext().getColor(R.color.textDark), ThemePrefs.textButtonColor) + ) + ) + binding.choices.setOnCheckedChangeListener { _, _ -> updateButtonState(button) } + binding.quantity.doAfterTextChanged { updateButtonState(button) } + } + + private fun updateButtonState(button: Button) { + button.isEnabled = binding.choices.checkedRadioButtonId != -1 && binding.quantity.text.isNotEmpty() + } + + companion object { + fun newInstance() = CustomReminderDialog() + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt b/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt index 68c048d916..79ca2d53e8 100644 --- a/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt @@ -213,14 +213,20 @@ class CourseBrowserFragment : Fragment(), FragmentInteractions, AppBarLayout.OnO // Load Pages List if (tabs.any { it.tabId == Tab.PAGES_ID }) { // Do not load the pages list if the tab is hidden or locked. - RouteMatcher.route(requireActivity(), TabHelper.getRouteByTabId(tab, canvasContext)) + val route = TabHelper.getRouteByTabId(tab, canvasContext) + route?.arguments = route?.arguments?.apply { + putString(PageDetailsFragment.PAGE_NAME, homePageTitle) + } ?: Bundle() + RouteMatcher.route(requireActivity(), route) } // If the home tab is a Page and we clicked it lets route directly there. - RouteMatcher.route( - requireActivity(), - PageDetailsFragment.makeRoute(canvasContext, Page.FRONT_PAGE_NAME) - .apply { ignoreDebounce = true }) + val route = PageDetailsFragment.makeFrontPageRoute(canvasContext) + .apply { ignoreDebounce = true } + route.arguments = route.arguments.apply { + putString(PageDetailsFragment.PAGE_NAME, homePageTitle) + } + RouteMatcher.route(requireActivity(), route) } else { val route = TabHelper.getRouteByTabId(tab, canvasContext)?.apply { ignoreDebounce = true } RouteMatcher.route(requireActivity(), route) diff --git a/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt b/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt index ef195aaa84..1884f27bfd 100644 --- a/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt +++ b/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt @@ -27,11 +27,13 @@ import com.instructure.pandautils.services.PushNotificationRegistrationWorker import com.instructure.student.R import com.instructure.student.activity.InternalWebViewActivity import com.instructure.student.activity.NavigationActivity +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.tasks.StudentLogoutTask class StudentAcceptableUsePolicyRouter( private val activity: FragmentActivity, - private val databaseProvider: DatabaseProvider + private val databaseProvider: DatabaseProvider, + private val alarmScheduler: AlarmScheduler ) : AcceptableUsePolicyRouter { override fun openPolicy(content: String) { @@ -54,6 +56,6 @@ class StudentAcceptableUsePolicyRouter( } override fun logout() { - StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider).execute() + StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider, alarmScheduler = alarmScheduler).execute() } } \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/login/StudentLoginNavigation.kt b/apps/student/src/main/java/com/instructure/student/features/login/StudentLoginNavigation.kt index 8c562f1760..a11e91a18a 100644 --- a/apps/student/src/main/java/com/instructure/student/features/login/StudentLoginNavigation.kt +++ b/apps/student/src/main/java/com/instructure/student/features/login/StudentLoginNavigation.kt @@ -25,16 +25,18 @@ import com.instructure.loginapi.login.tasks.LogoutTask import com.instructure.pandautils.room.offline.DatabaseProvider import com.instructure.pandautils.services.PushNotificationRegistrationWorker import com.instructure.student.activity.NavigationActivity +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.tasks.StudentLogoutTask class StudentLoginNavigation( private val activity: FragmentActivity, - private val databaseProvider: DatabaseProvider + private val databaseProvider: DatabaseProvider, + private val alarmScheduler: AlarmScheduler ) : LoginNavigation(activity) { override val checkElementary: Boolean = true override fun logout() { - StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider).execute() + StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider, alarmScheduler = alarmScheduler).execute() } override fun initMainActivityIntent(): Intent { diff --git a/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt index ae55c2f3ef..fba5a92eac 100644 --- a/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt @@ -51,7 +51,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import org.greenrobot.eventbus.Subscribe import java.util.* -import java.util.regex.Pattern +import java.util.regex.* import javax.inject.Inject @ScreenView(SCREEN_VIEW_PAGE_DETAILS) @@ -67,6 +67,7 @@ class PageDetailsFragment : InternalWebviewFragment(), Bookmarkable { private var page: Page by ParcelableArg(default = Page(), key = PAGE) private var pageUrl: String? by NullableStringArg(key = PAGE_URL) private var navigatedFromModules: Boolean by BooleanArg(key = NAVIGATED_FROM_MODULES) + private var frontPage: Boolean by BooleanArg(key = FRONT_PAGE) // Flag for the webview client to know whether or not we should clear the history private var isUpdated = false @@ -152,11 +153,11 @@ class PageDetailsFragment : InternalWebviewFragment(), Bookmarkable { } else { loadFailedPageInfo(null) } - } else if (pageName == null || pageName == Page.FRONT_PAGE_NAME) fetchFontPage() + } else if (frontPage) fetchFrontPage() else fetchPageDetails() } - private fun fetchFontPage() { + private fun fetchFrontPage() { lifecycleScope.tryLaunch { val result = repository.getFrontPage(canvasContext, true) result.onSuccess { @@ -331,6 +332,7 @@ class PageDetailsFragment : InternalWebviewFragment(), Bookmarkable { const val PAGE = "pageDetails" const val PAGE_URL = "pageUrl" const val NAVIGATED_FROM_MODULES = "navigated_from_modules" + private const val FRONT_PAGE = "frontPage" fun newInstance(route: Route): PageDetailsFragment? { return if (validRoute(route)) PageDetailsFragment().apply { @@ -349,9 +351,9 @@ class PageDetailsFragment : InternalWebviewFragment(), Bookmarkable { route.paramsHash.containsKey(RouterParams.PAGE_ID)) } - fun makeRoute(canvasContext: CanvasContext, pageName: String?): Route { + fun makeFrontPageRoute(canvasContext: CanvasContext): Route { return Route(null, PageDetailsFragment::class.java, canvasContext, canvasContext.makeBundle(Bundle().apply { - if (pageName != null) putString(PAGE_NAME, pageName) + putBoolean(FRONT_PAGE, true) })) } diff --git a/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt index 740d0c4816..cb47a42ce2 100644 --- a/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt @@ -129,9 +129,8 @@ class PageListFragment : ParentFragment(), Bookmarkable { super.onActivityCreated(savedInstanceState) if (isShowFrontPage) { - val route = PageDetailsFragment.makeRoute( - canvasContext, - Page.FRONT_PAGE_NAME + val route = PageDetailsFragment.makeFrontPageRoute( + canvasContext ).apply { ignoreDebounce = true} RouteMatcher.route(requireActivity(), route) } diff --git a/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListRecyclerAdapter.kt index a74128b780..01cf25e3f6 100644 --- a/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListRecyclerAdapter.kt @@ -41,15 +41,24 @@ import kotlinx.coroutines.CoroutineScope import java.util.Locale class PeopleListRecyclerAdapter( - context: Context, - private val lifecycleScope: CoroutineScope, - private val repository: PeopleListRepository, - private val canvasContext: CanvasContext, - private val adapterToFragmentCallback: AdapterToFragmentCallback -) : ExpandableRecyclerAdapter(context, EnrollmentType::class.java, User::class.java) { + context: Context, + private val lifecycleScope: CoroutineScope, + private val repository: PeopleListRepository, + private val canvasContext: CanvasContext, + private val adapterToFragmentCallback: AdapterToFragmentCallback +) : ExpandableRecyclerAdapter( + context, + EnrollmentType::class.java, + User::class.java +) { private val mCourseColor = canvasContext.backgroundColor - private val mEnrollmentPriority = mapOf( EnrollmentType.Teacher to 4, EnrollmentType.Ta to 3, EnrollmentType.Student to 2, EnrollmentType.Observer to 1) + private val mEnrollmentPriority = mapOf( + EnrollmentType.Teacher to 4, + EnrollmentType.Ta to 3, + EnrollmentType.Student to 2, + EnrollmentType.Observer to 1 + ) init { isExpandedByDefault = true @@ -58,16 +67,18 @@ class PeopleListRecyclerAdapter( override fun loadFirstPage() { lifecycleScope.tryLaunch { - var canvasContext = canvasContext - - // If the canvasContext is a group, and has a course we want to add the Teachers and TAs from that course to the peoples list - if (CanvasContext.Type.isGroup(this@PeopleListRecyclerAdapter.canvasContext) && (this@PeopleListRecyclerAdapter.canvasContext as Group).courseId > 0) { - // We build a generic CanvasContext with type set to COURSE and give it the CourseId from the group, so that it wil use the course API not the group API - canvasContext = CanvasContext.getGenericContext(CanvasContext.Type.COURSE, this@PeopleListRecyclerAdapter.canvasContext.courseId, "") - } - - val teachers = repository.loadTeachers(canvasContext, isRefresh) - val tas = repository.loadTAs(canvasContext, isRefresh) + val teacherContext = + if (CanvasContext.Type.isGroup(this@PeopleListRecyclerAdapter.canvasContext) && (this@PeopleListRecyclerAdapter.canvasContext as Group).courseId > 0) { + // We build a generic CanvasContext with type set to COURSE and give it the CourseId from the group, so that it wil use the course API not the group API + CanvasContext.getGenericContext( + CanvasContext.Type.COURSE, + this@PeopleListRecyclerAdapter.canvasContext.courseId, + "" + ) + } else canvasContext + + val teachers = repository.loadTeachers(teacherContext, isRefresh) + val tas = repository.loadTAs(teacherContext, isRefresh) val peopleFirstPage = repository.loadFirstPagePeople(canvasContext, isRefresh) val result = teachers.dataOrThrow + tas.dataOrThrow + peopleFirstPage.dataOrThrow @@ -102,38 +113,59 @@ class PeopleListRecyclerAdapter( private fun populateAdapter(result: List) { val (enrolled, unEnrolled) = result.partition { it.enrollments.isNotEmpty() } enrolled - .groupBy { - it.enrollments.sortedByDescending { enrollment -> mEnrollmentPriority[enrollment.type] }[0].type - } - .forEach { (type, users) -> addOrUpdateAllItems(type!!, users) } + .groupBy { + it.enrollments.sortedByDescending { enrollment -> mEnrollmentPriority[enrollment.type] }[0].type + } + .forEach { (type, users) -> addOrUpdateAllItems(type!!, users) } if (CanvasContext.Type.isGroup(canvasContext)) addOrUpdateAllItems(EnrollmentType.NoEnrollment, unEnrolled) notifyDataSetChanged() adapterToFragmentCallback.onRefreshFinished() } override fun createViewHolder(v: View, viewType: Int): RecyclerView.ViewHolder = - if (viewType == Types.TYPE_HEADER) PeopleHeaderViewHolder(v) else PeopleViewHolder(v) + if (viewType == Types.TYPE_HEADER) PeopleHeaderViewHolder(v) else PeopleViewHolder(v) override fun itemLayoutResId(viewType: Int): Int = - if (viewType == Types.TYPE_HEADER) PeopleHeaderViewHolder.HOLDER_RES_ID else PeopleViewHolder.HOLDER_RES_ID + if (viewType == Types.TYPE_HEADER) PeopleHeaderViewHolder.HOLDER_RES_ID else PeopleViewHolder.HOLDER_RES_ID override fun contextReady() = Unit override fun onBindChildHolder(holder: RecyclerView.ViewHolder, peopleGroupType: EnrollmentType, user: User) { val groupItemCount = getGroupItemCount(peopleGroupType) val itemPosition = storedIndexOfItem(peopleGroupType, user) - (holder as PeopleViewHolder).bind(user, adapterToFragmentCallback, mCourseColor, itemPosition == 0, itemPosition == groupItemCount - 1) + (holder as PeopleViewHolder).bind( + user, + adapterToFragmentCallback, + mCourseColor, + itemPosition == 0, + itemPosition == groupItemCount - 1 + ) } - override fun onBindHeaderHolder(holder: RecyclerView.ViewHolder, enrollmentType: EnrollmentType, isExpanded: Boolean) { - (holder as PeopleHeaderViewHolder).bind(enrollmentType, getHeaderTitle(enrollmentType), isExpanded, viewHolderHeaderClicked) + override fun onBindHeaderHolder( + holder: RecyclerView.ViewHolder, + enrollmentType: EnrollmentType, + isExpanded: Boolean + ) { + (holder as PeopleHeaderViewHolder).bind( + enrollmentType, + getHeaderTitle(enrollmentType), + isExpanded, + viewHolderHeaderClicked + ) } override fun createGroupCallback(): GroupSortedList.GroupComparatorCallback { return object : GroupSortedList.GroupComparatorCallback { - override fun compare(o1: EnrollmentType, o2: EnrollmentType) = getHeaderTitle(o2).compareTo(getHeaderTitle(o1)) - override fun areContentsTheSame(oldGroup: EnrollmentType, newGroup: EnrollmentType) = getHeaderTitle(oldGroup) == getHeaderTitle(newGroup) - override fun areItemsTheSame(group1: EnrollmentType, group2: EnrollmentType) = getHeaderTitle(group1) == getHeaderTitle(group2) + override fun compare(o1: EnrollmentType, o2: EnrollmentType) = + getHeaderTitle(o2).compareTo(getHeaderTitle(o1)) + + override fun areContentsTheSame(oldGroup: EnrollmentType, newGroup: EnrollmentType) = + getHeaderTitle(oldGroup) == getHeaderTitle(newGroup) + + override fun areItemsTheSame(group1: EnrollmentType, group2: EnrollmentType) = + getHeaderTitle(group1) == getHeaderTitle(group2) + override fun getUniqueGroupId(group: EnrollmentType) = getHeaderTitle(group).hashCode().toLong() override fun getGroupType(group: EnrollmentType) = Types.TYPE_HEADER } @@ -141,7 +173,11 @@ class PeopleListRecyclerAdapter( override fun createItemCallback(): GroupSortedList.ItemComparatorCallback { return object : GroupSortedList.ItemComparatorCallback { - override fun compare(group: EnrollmentType, o1: User, o2: User) = NaturalOrderComparator.compare(o1.sortableName?.lowercase(Locale.getDefault()).orEmpty(), o2.sortableName?.lowercase(Locale.getDefault()).orEmpty()) + override fun compare(group: EnrollmentType, o1: User, o2: User) = NaturalOrderComparator.compare( + o1.sortableName?.lowercase(Locale.getDefault()).orEmpty(), + o2.sortableName?.lowercase(Locale.getDefault()).orEmpty() + ) + override fun areContentsTheSame(oldItem: User, newItem: User) = oldItem.sortableName == newItem.sortableName override fun areItemsTheSame(item1: User, item2: User) = item1.id == item2.id override fun getUniqueItemId(item: User) = item.id diff --git a/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt index b599ab9883..2ba2396090 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt @@ -25,6 +25,8 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem +import android.view.MotionEvent +import android.view.MotionEvent.ACTION_CANCEL import android.view.View import android.view.ViewGroup import androidx.lifecycle.lifecycleScope @@ -36,6 +38,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.work.WorkInfo.State import androidx.work.WorkManager import androidx.work.WorkQuery +import com.google.firebase.crashlytics.FirebaseCrashlytics import com.instructure.canvasapi2.managers.CourseNicknameManager import com.instructure.canvasapi2.managers.UserManager import com.instructure.canvasapi2.models.* @@ -99,6 +102,9 @@ class DashboardFragment : ParentFragment() { @Inject lateinit var workManager: WorkManager + @Inject + lateinit var firebaseCrashlytics: FirebaseCrashlytics + private val binding by viewBinding(FragmentCourseGridBinding::bind) private lateinit var recyclerBinding: CourseGridRecyclerRefreshLayoutBinding @@ -357,6 +363,10 @@ class DashboardFragment : ParentFragment() { addItemTouchHelperForCardReorder() } + fun cancelCardDrag() { + recyclerBinding.listView.onTouchEvent(MotionEvent.obtain(0L, 0L, ACTION_CANCEL, 0f, 0f, 0)) + } + private fun addItemTouchHelperForCardReorder() { val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.START or ItemTouchHelper.END or ItemTouchHelper.DOWN or ItemTouchHelper.UP, @@ -414,10 +424,17 @@ class DashboardFragment : ParentFragment() { ) { val finishingPosition = viewHolder.bindingAdapterPosition + if (finishingPosition == RecyclerView.NO_POSITION) { + itemToMove = null + firebaseCrashlytics.recordException(Throwable("Failed to reorder dashboard. finishingPosition == RecyclerView.NO_POSITION")) + toast(R.string.failedToUpdateDashboardOrder) + return + } + itemToMove?.let { recyclerAdapter?.moveItems(DashboardRecyclerAdapter.ItemType.COURSE_HEADER, it, finishingPosition - 1) recyclerAdapter?.notifyDataSetChanged() - itemToMove == null + itemToMove = null } val courseItems = recyclerAdapter?.getItems(DashboardRecyclerAdapter.ItemType.COURSE_HEADER) diff --git a/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt index 49bb171205..558b7761ba 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt @@ -20,7 +20,7 @@ import androidx.fragment.app.Fragment import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.interactions.router.Route -import com.instructure.pandautils.utils.FontFamily +import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.R import com.instructure.student.fragment.CalendarFragment import com.instructure.student.fragment.DashboardFragment @@ -46,8 +46,8 @@ class DefaultNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationBeha override val visibleAccountMenuItems: Set = setOf(AccountMenuItem.HELP, AccountMenuItem.CHANGE_USER, AccountMenuItem.LOGOUT) - override val fontFamily: FontFamily - get() = FontFamily.REGULAR + override val canvasFont: CanvasFont + get() = CanvasFont.REGULAR override val bottomBarMenu: Int = R.menu.bottom_bar_menu diff --git a/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt index 37bbbfa2ea..9c36505bea 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt @@ -20,7 +20,7 @@ import androidx.fragment.app.Fragment import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.interactions.router.Route -import com.instructure.pandautils.utils.FontFamily +import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.R import com.instructure.student.fragment.CalendarFragment import com.instructure.student.fragment.NotificationListFragment @@ -46,8 +46,8 @@ class ElementaryNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationB override val visibleAccountMenuItems: Set = setOf(AccountMenuItem.HELP, AccountMenuItem.CHANGE_USER, AccountMenuItem.LOGOUT) - override val fontFamily: FontFamily - get() = FontFamily.K5 + override val canvasFont: CanvasFont + get() = CanvasFont.K5 override val bottomBarMenu: Int = R.menu.bottom_bar_menu_elementary diff --git a/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt index e650131e0b..7ac1999403 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt @@ -22,7 +22,7 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.interactions.router.Route import com.instructure.pandautils.features.inbox.list.InboxFragment -import com.instructure.pandautils.utils.FontFamily +import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.activity.NothingToSeeHereFragment import com.instructure.student.fragment.ParentFragment @@ -39,7 +39,7 @@ interface NavigationBehavior { val visibleAccountMenuItems: Set - val fontFamily: FontFamily + val canvasFont: CanvasFont @get:MenuRes val bottomBarMenu: Int diff --git a/apps/student/src/main/java/com/instructure/student/receivers/AlarmReceiver.kt b/apps/student/src/main/java/com/instructure/student/receivers/AlarmReceiver.kt new file mode 100644 index 0000000000..0f02fe27f8 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/receivers/AlarmReceiver.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2024 - 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.receivers + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.instructure.pandautils.models.PushNotification +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.pandautils.utils.Const +import com.instructure.student.R +import com.instructure.student.activity.NavigationActivity +import com.instructure.student.util.goAsync +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class AlarmReceiver : BroadcastReceiver() { + + @Inject + lateinit var reminderDao: ReminderDao + + override fun onReceive(context: Context?, intent: Intent?) { + if (context != null && intent != null) { + val assignmentId = intent.getLongExtra(ASSIGNMENT_ID, 0L) + val assignmentPath = intent.getStringExtra(ASSIGNMENT_PATH) ?: return + val assignmentName = intent.getStringExtra(ASSIGNMENT_NAME) ?: return + val dueIn = intent.getStringExtra(DUE_IN) ?: return + + createNotificationChannel(context) + showNotification(context, assignmentId, assignmentPath, assignmentName, dueIn) + goAsync { + reminderDao.deletePastReminders(System.currentTimeMillis()) + } + } + } + + private fun showNotification(context: Context, assignmentId: Long, assignmentPath: String, assignmentName: String, dueIn: String) { + val intent = Intent(context, NavigationActivity.startActivityClass).apply { + putExtra(Const.LOCAL_NOTIFICATION, true) + putExtra(PushNotification.HTML_URL, assignmentPath) + } + + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_canvas_logo) + .setContentTitle(context.getString(R.string.reminderNotificationTitle)) + .setContentText(context.getString(R.string.reminderNotificationDescription, dueIn, assignmentName)) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(assignmentId.toInt(), builder.build()) + } + + private fun createNotificationChannel(context: Context) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.reminderNotificationChannelName), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = context.getString(R.string.reminderNotificationChannelDescription) + } + + val notificationManager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + companion object { + private const val CHANNEL_ID = "REMINDERS_CHANNEL_ID" + const val ASSIGNMENT_ID = "ASSIGNMENT_ID" + const val ASSIGNMENT_PATH = "ASSIGNMENT_PATH" + const val ASSIGNMENT_NAME = "ASSIGNMENT_NAME" + const val DUE_IN = "DUE_IN" + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/receivers/InitializeReceiver.kt b/apps/student/src/main/java/com/instructure/student/receivers/InitializeReceiver.kt index 090bf55a72..90560f5f20 100644 --- a/apps/student/src/main/java/com/instructure/student/receivers/InitializeReceiver.kt +++ b/apps/student/src/main/java/com/instructure/student/receivers/InitializeReceiver.kt @@ -20,18 +20,29 @@ package com.instructure.student.receivers import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import com.instructure.pandautils.receivers.PushExternalReceiver import com.instructure.student.R import com.instructure.student.activity.NavigationActivity +import com.instructure.student.features.assignments.reminder.AlarmScheduler +import com.instructure.student.util.goAsync import com.instructure.student.widget.WidgetUpdater -import com.instructure.pandautils.receivers.PushExternalReceiver +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +@AndroidEntryPoint class InitializeReceiver : BroadcastReceiver() { + @Inject + lateinit var alarmScheduler: AlarmScheduler + override fun onReceive(context: Context, intent: Intent) { if(Intent.ACTION_BOOT_COMPLETED == intent.action || Intent.ACTION_MY_PACKAGE_REPLACED == intent.action) { //Restores stored push notifications upon boot PushExternalReceiver.postStoredNotifications(context, context.getString(R.string.student_app_name), NavigationActivity.startActivityClass, R.color.login_studentAppTheme) WidgetUpdater.updateWidgets() + goAsync { + alarmScheduler.scheduleAllAlarmsForCurrentUser() + } } } } diff --git a/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt b/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt index 59360f590b..e695af1e4f 100644 --- a/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt +++ b/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt @@ -29,6 +29,7 @@ import com.instructure.pandautils.features.offline.sync.OfflineSyncWorker import com.instructure.pandautils.room.offline.DatabaseProvider import com.instructure.pandautils.typeface.TypefaceBehavior import com.instructure.student.activity.LoginActivity +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.flutterChannels.FlutterComm import com.instructure.student.util.StudentPrefs import com.instructure.student.widget.WidgetUpdater @@ -39,7 +40,8 @@ class StudentLogoutTask( uri: Uri? = null, canvasForElementaryFeatureFlag: Boolean = false, typefaceBehavior: TypefaceBehavior? = null, - private val databaseProvider: DatabaseProvider? = null + private val databaseProvider: DatabaseProvider? = null, + private val alarmScheduler: AlarmScheduler? = null ) : LogoutTask(type, uri, canvasForElementaryFeatureFlag, typefaceBehavior) { override fun onCleanup() { @@ -82,4 +84,8 @@ class StudentLogoutTask( cancelAllWorkByTag(OfflineSyncWorker.ONE_TIME_TAG) } } + + override suspend fun cancelAlarms() { + alarmScheduler?.cancelAllAlarmsForCurrentUser() + } } diff --git a/apps/student/src/main/java/com/instructure/student/util/AppManager.kt b/apps/student/src/main/java/com/instructure/student/util/AppManager.kt index 20a7c1fa77..c43f7fb665 100644 --- a/apps/student/src/main/java/com/instructure/student/util/AppManager.kt +++ b/apps/student/src/main/java/com/instructure/student/util/AppManager.kt @@ -23,6 +23,7 @@ import com.instructure.canvasapi2.utils.MasqueradeHelper import com.instructure.loginapi.login.tasks.LogoutTask import com.instructure.pandautils.room.offline.DatabaseProvider import com.instructure.pandautils.typeface.TypefaceBehavior +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.tasks.StudentLogoutTask import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @@ -39,15 +40,28 @@ class AppManager : BaseAppManager() { @Inject lateinit var databaseProvider: DatabaseProvider + @Inject + lateinit var alarmScheduler: AlarmScheduler + override fun onCreate() { super.onCreate() MasqueradeHelper.masqueradeLogoutTask = Runnable { - StudentLogoutTask(LogoutTask.Type.LOGOUT, typefaceBehavior = typefaceBehavior, databaseProvider = databaseProvider).execute() + StudentLogoutTask( + LogoutTask.Type.LOGOUT, + typefaceBehavior = typefaceBehavior, + databaseProvider = databaseProvider, + alarmScheduler = alarmScheduler + ).execute() } } override fun performLogoutOnAuthError() { - StudentLogoutTask(LogoutTask.Type.LOGOUT, typefaceBehavior = typefaceBehavior, databaseProvider = databaseProvider).execute() + StudentLogoutTask( + LogoutTask.Type.LOGOUT, + typefaceBehavior = typefaceBehavior, + databaseProvider = databaseProvider, + alarmScheduler = alarmScheduler + ).execute() } override fun getWorkManagerFactory(): WorkerFactory = workerFactory diff --git a/apps/student/src/main/java/com/instructure/student/util/Extensions.kt b/apps/student/src/main/java/com/instructure/student/util/Extensions.kt index 4190da75d2..110f6fd2c9 100644 --- a/apps/student/src/main/java/com/instructure/student/util/Extensions.kt +++ b/apps/student/src/main/java/com/instructure/student/util/Extensions.kt @@ -15,6 +15,7 @@ */ package com.instructure.student.util +import android.content.BroadcastReceiver import android.content.Context import com.instructure.canvasapi2.managers.ExternalToolManager import com.instructure.canvasapi2.models.Assignment @@ -24,8 +25,14 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult import com.instructure.pandautils.utils.getShortMonthAndDay import com.instructure.pandautils.utils.getTime +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import org.threeten.bp.OffsetDateTime -import java.util.* +import java.util.Locale +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext suspend fun Long.isStudioEnabled(): Boolean { val context = CanvasContext.getGenericContext(CanvasContext.Type.COURSE, this) @@ -52,3 +59,18 @@ fun String.toDueAtString(context: Context): String { val dueDateTime = OffsetDateTime.parse(this).withOffsetSameInstant(OffsetDateTime.now().offset) return context.getString(com.instructure.pandares.R.string.submissionDetailsDueAt, dueDateTime.getShortMonthAndDay(), dueDateTime.getTime()) } + +fun BroadcastReceiver.goAsync( + context: CoroutineContext = EmptyCoroutineContext, + block: suspend CoroutineScope.() -> Unit +) { + val pendingResult = goAsync() + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(context) { + try { + block() + } finally { + pendingResult.finish() + } + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/util/TabHelper.kt b/apps/student/src/main/java/com/instructure/student/util/TabHelper.kt index 4b77b5f92d..b3fd8de5ee 100644 --- a/apps/student/src/main/java/com/instructure/student/util/TabHelper.kt +++ b/apps/student/src/main/java/com/instructure/student/util/TabHelper.kt @@ -19,7 +19,6 @@ package com.instructure.student.util import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course -import com.instructure.canvasapi2.models.Page import com.instructure.canvasapi2.models.Tab import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.ContextKeeper @@ -36,7 +35,11 @@ import com.instructure.student.features.pages.details.PageDetailsFragment import com.instructure.student.features.pages.list.PageListFragment import com.instructure.student.features.people.list.PeopleListFragment import com.instructure.student.features.quiz.list.QuizListFragment -import com.instructure.student.fragment.* +import com.instructure.student.fragment.AnnouncementListFragment +import com.instructure.student.fragment.CourseSettingsFragment +import com.instructure.student.fragment.LtiLaunchFragment +import com.instructure.student.fragment.NotificationListFragment +import com.instructure.student.fragment.UnsupportedTabFragment import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListRepositoryFragment import com.instructure.student.mobius.syllabus.ui.SyllabusRepositoryFragment import java.util.* @@ -97,7 +100,7 @@ object TabHelper { Tab.ASSIGNMENTS_ID -> AssignmentListFragment.makeRoute(canvasContext) Tab.MODULES_ID -> ModuleListFragment.makeRoute(canvasContext) Tab.PAGES_ID -> PageListFragment.makeRoute(canvasContext, false) - Tab.FRONT_PAGE_ID -> PageDetailsFragment.makeRoute(canvasContext, Page.FRONT_PAGE_NAME) + Tab.FRONT_PAGE_ID -> PageDetailsFragment.makeFrontPageRoute(canvasContext) Tab.DISCUSSIONS_ID -> DiscussionListFragment.makeRoute(canvasContext) Tab.PEOPLE_ID -> PeopleListFragment.makeRoute(canvasContext) Tab.FILES_ID -> FileListFragment.makeRoute(canvasContext) diff --git a/apps/student/src/main/res/layout/dialog_custom_reminder.xml b/apps/student/src/main/res/layout/dialog_custom_reminder.xml new file mode 100644 index 0000000000..3f5ad32e2c --- /dev/null +++ b/apps/student/src/main/res/layout/dialog_custom_reminder.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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 37953ecadd..c4e135faa7 100644 --- a/apps/student/src/main/res/layout/fragment_assignment_details.xml +++ b/apps/student/src/main/res/layout/fragment_assignment_details.xml @@ -1,4 +1,19 @@ - + @@ -281,6 +296,77 @@ android:visibility="@{viewModel.data.dueDate.empty ? View.GONE : View.VISIBLE}" app:layout_constraintTop_toBottomOf="@id/dueLabel" /> + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/reminderBottomDivider" /> + + \ No newline at end of file diff --git a/apps/student/src/main/res/layout/view_reminder.xml b/apps/student/src/main/res/layout/view_reminder.xml new file mode 100644 index 0000000000..806327e22d --- /dev/null +++ b/apps/student/src/main/res/layout/view_reminder.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt b/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt index cd3341086c..0cdb25a7cf 100644 --- a/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt +++ b/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt @@ -24,6 +24,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContext @@ -33,11 +34,13 @@ import com.instructure.canvasapi2.models.Enrollment import com.instructure.canvasapi2.models.LockInfo import com.instructure.canvasapi2.models.Quiz import com.instructure.canvasapi2.models.Submission +import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.canvasapi2.utils.toApiString import com.instructure.pandautils.R import com.instructure.pandautils.mvvm.ViewState +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.HtmlContentFormatter @@ -45,8 +48,12 @@ import com.instructure.student.db.StudentDb import com.instructure.student.features.assignments.details.AssignmentDetailAction import com.instructure.student.features.assignments.details.AssignmentDetailsRepository import com.instructure.student.features.assignments.details.AssignmentDetailsViewModel +import com.instructure.student.features.assignments.details.ReminderChoice +import com.instructure.student.features.assignments.details.ReminderViewData import com.instructure.student.features.assignments.details.gradecellview.GradeCellViewData +import com.instructure.student.features.assignments.reminder.AlarmScheduler import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -81,6 +88,7 @@ class AssignmentDetailsViewModelTest { private val application: Application = mockk(relaxed = true) private val apiPrefs: ApiPrefs = mockk(relaxed = true) private val database: StudentDb = mockk(relaxed = true) + private val alarmScheduler: AlarmScheduler = mockk(relaxed = true) @Before fun setUp() { @@ -97,6 +105,9 @@ class AssignmentDetailsViewModelTest { every { savedStateHandle.get(Const.CANVAS_CONTEXT) } returns Course() every { savedStateHandle.get(Const.ASSIGNMENT_ID) } returns 0L + + every { assignmentDetailsRepository.getRemindersByAssignmentIdLiveData(any(), any()) } returns MutableLiveData() + every { apiPrefs.user } returns User(id = 1) } fun tearDown() { @@ -111,6 +122,7 @@ class AssignmentDetailsViewModelTest { colorKeeper, application, apiPrefs, + alarmScheduler, database ) @@ -775,4 +787,196 @@ class AssignmentDetailsViewModelTest { Assert.assertFalse(viewModel.data.value?.submitVisible!!) } + + @Test + fun `Reminder section is not visible if there's no future deadline`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + + val assignment = Assignment(name = "Test", submissionTypesRaw = listOf("online_text_entry")) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + Assert.assertFalse(viewModel.data.value?.showReminders!!) + } + + @Test + fun `Reminder section visible if there's a future deadline`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + Assert.assertTrue(viewModel.data.value?.showReminders!!) + } + + @Test + fun `Reminders map correctly`() { + val reminderEntities = listOf( + ReminderEntity(1, 1, 1, "htmlUrl1", "Assignment 1", "1 day", 1000), + ReminderEntity(2, 1, 1, "htmlUrl2", "Assignment 2", "2 days", 2000), + ReminderEntity(3, 1, 1, "htmlUrl3", "Assignment 3", "3 days", 3000) + ) + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + every { assignmentDetailsRepository.getRemindersByAssignmentIdLiveData(any(), any()) } returns MutableLiveData(reminderEntities) + every { resources.getString(eq(R.string.reminderBefore), any()) } answers { call -> "${(call.invocation.args[1] as Array<*>)[0]} Before" } + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + Assert.assertEquals( + reminderEntities.map { ReminderViewData(it.id, "${it.text} Before") }, + viewModel.data.value?.reminders?.map { it.data } + ) + } + + @Test + fun `Reminders update correctly`() { + val remindersLiveData = MutableLiveData>() + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + every { assignmentDetailsRepository.getRemindersByAssignmentIdLiveData(any(), any()) } returns remindersLiveData + every { resources.getString(eq(R.string.reminderBefore), any()) } answers { call -> "${(call.invocation.args[1] as Array<*>)[0]} Before" } + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + Assert.assertEquals(0, viewModel.data.value?.reminders?.size) + + remindersLiveData.value = listOf(ReminderEntity(1, 1, 1, "htmlUrl1", "Assignment 1", "1 day", 1000)) + + Assert.assertEquals(ReminderViewData(1, "1 day Before"), viewModel.data.value?.reminders?.first()?.data) + } + + @Test + fun `Add reminder posts action`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + viewModel.onAddReminderClicked() + + Assert.assertEquals(AssignmentDetailAction.ShowReminderDialog, viewModel.events.value?.peekContent()) + } + + @Test + fun `Selected reminder choice`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + every { savedStateHandle.get(Const.ASSIGNMENT_ID) } returns 1 + every { resources.getQuantityString(R.plurals.reminderDay, 3, 3) } returns "3 days" + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 4) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + viewModel.onReminderSelected(ReminderChoice.Day(3)) + + val time = assignment.dueDate?.time?.minus(3 * 24 * 60 * 60 * 1000L) + + coVerify(exactly = 1) { + assignmentDetailsRepository.addReminder(1, assignment, "3 days", time!!) + } + } + + @Test + fun `Selected reminder choice custom`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + viewModel.onReminderSelected(ReminderChoice.Custom) + + Assert.assertEquals(AssignmentDetailAction.ShowCustomReminderDialog, viewModel.events.value?.peekContent()) + } + + @Test + fun `Selected past reminder choice`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + every { savedStateHandle.get(Const.ASSIGNMENT_ID) } returns 1 + every { resources.getQuantityString(R.plurals.reminderDay, 3, 3) } returns "3 days" + every { resources.getString(R.string.reminderInPast) } returns "Reminder in past" + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 2) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + viewModel.onReminderSelected(ReminderChoice.Day(3)) + + Assert.assertEquals(AssignmentDetailAction.ShowToast("Reminder in past"), viewModel.events.value?.peekContent()) + } + + @Test + fun `Selected reminder already set up`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + every { savedStateHandle.get(Const.ASSIGNMENT_ID) } returns 1 + every { resources.getQuantityString(R.plurals.reminderDay, 3, 3) } returns "3 days" + every { resources.getString(R.string.reminderAlreadySet) } returns "Reminder in past" + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 4) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + val time = assignment.dueDate?.time?.minus(3 * 24 * 60 * 60 * 1000L) + val reminderEntities = listOf( + ReminderEntity(1, 1, 1, "htmlUrl1", "Assignment 1", "1 day", time!!) + ) + every { assignmentDetailsRepository.getRemindersByAssignmentIdLiveData(any(), any()) } returns MutableLiveData(reminderEntities) + + val viewModel = getViewModel() + + viewModel.onReminderSelected(ReminderChoice.Day(3)) + + Assert.assertEquals(AssignmentDetailAction.ShowToast("Reminder in past"), viewModel.events.value?.peekContent()) + } } diff --git a/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/reminder/AlarmSchedulerTest.kt b/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/reminder/AlarmSchedulerTest.kt new file mode 100644 index 0000000000..c19a555fb9 --- /dev/null +++ b/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/reminder/AlarmSchedulerTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2024 - 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.features.assignmentdetails.reminder + +import android.app.AlarmManager +import android.content.Context +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity +import com.instructure.student.features.assignments.reminder.AlarmScheduler +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class AlarmSchedulerTest { + + private val context: Context = mockk(relaxed = true) + private val reminderDao: ReminderDao = mockk(relaxed = true) + private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val alarmManager: AlarmManager = mockk(relaxed = true) + + @Before + fun setup() { + every { context.getSystemService(Context.ALARM_SERVICE) } returns alarmManager + } + + @Test + fun `Test schedule all alarms for the current user`() = runTest { + val alarmScheduler = spyk(AlarmScheduler(context, reminderDao, apiPrefs)) + + val reminder1 = ReminderEntity(1, 1, 1, "path1", "Assignment 1", "1 day", 12345678) + val reminder2 = ReminderEntity(2, 1, 2, "path2", "Assignment 2", "2 hours", 12345678) + + every { apiPrefs.user } returns User(id = 1) + coEvery { reminderDao.findByUserId(1) } returns listOf(reminder1, reminder2) + + coEvery { alarmScheduler.scheduleAlarm(any(), any(), any(), any(), any(), any()) } just Runs + coEvery { alarmScheduler.scheduleAllAlarmsForCurrentUser() } answers { callOriginal() } + + alarmScheduler.scheduleAllAlarmsForCurrentUser() + + coVerify { + alarmScheduler.scheduleAlarm(reminder1.assignmentId, reminder1.htmlUrl, reminder1.name, reminder1.text, reminder1.time, reminder1.id) + alarmScheduler.scheduleAlarm(reminder2.assignmentId, reminder2.htmlUrl, reminder2.name, reminder2.text, reminder2.time, reminder2.id) + } + } + + @Test + fun `Test cancel all alarms for the current user`() = runTest { + val alarmScheduler = spyk(AlarmScheduler(context, reminderDao, apiPrefs)) + + val reminder1 = ReminderEntity(1, 1, 1, "path1", "Assignment 1", "1 day", 12345678) + val reminder2 = ReminderEntity(2, 1, 2, "path2", "Assignment 2", "2 hours", 12345678) + + every { apiPrefs.user } returns User(id = 1) + coEvery { reminderDao.findByUserId(1) } returns listOf(reminder1, reminder2) + + coEvery { alarmScheduler.cancelAlarm(any()) } just Runs + coEvery { alarmScheduler.cancelAllAlarmsForCurrentUser() } answers { callOriginal() } + + alarmScheduler.cancelAllAlarmsForCurrentUser() + + coVerify { + alarmScheduler.cancelAlarm(reminder1.id) + alarmScheduler.cancelAlarm(reminder2.id) + } + } +} \ No newline at end of file diff --git a/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt b/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt index f569008123..ef8d127a38 100644 --- a/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt +++ b/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt @@ -17,11 +17,13 @@ package com.instructure.student.features.offline.assignmentdetails +import androidx.lifecycle.MutableLiveData import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.LTITool import com.instructure.canvasapi2.models.Quiz -import com.instructure.pandautils.utils.FEATURE_FLAG_OFFLINE +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.student.features.assignments.details.AssignmentDetailsRepository @@ -44,8 +46,9 @@ class AssignmentDetailsRepositoryTest { private val localDataSource: AssignmentDetailsLocalDataSource = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val reminderDao: ReminderDao = mockk(relaxed = true) - private val repository = AssignmentDetailsRepository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider) + private val repository = AssignmentDetailsRepository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider, reminderDao) @Before fun setup() = runTest { @@ -179,4 +182,41 @@ class AssignmentDetailsRepositoryTest { Assert.assertEquals(null, ltiTool) } + + @Test + fun `Get reminders liveData`() = runTest { + val expected = MutableLiveData>() + every { reminderDao.findByAssignmentIdLiveData(any(), any()) } returns expected + + val reminderLiveData = repository.getRemindersByAssignmentIdLiveData(1, 1) + + Assert.assertEquals(expected, reminderLiveData) + } + + @Test + fun `Delete reminder`() = runTest { + repository.deleteReminderById(1) + + coVerify(exactly = 1) { + reminderDao.deleteById(1) + } + } + + @Test + fun `Add reminder`() = runTest { + repository.addReminder(1, Assignment(1, name = "Assignment 1", htmlUrl = "htmlUrl"), "Test Reminder", 1000) + + coVerify(exactly = 1) { + reminderDao.insert( + ReminderEntity( + userId = 1, + assignmentId = 1, + htmlUrl = "htmlUrl", + name = "Assignment 1", + text = "Test Reminder", + time = 1000 + ) + ) + } + } } \ No newline at end of file diff --git a/apps/teacher/build.gradle b/apps/teacher/build.gradle index 8972440a88..ee6e4fa51d 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 = 61 - versionName = '1.27.0' + versionCode = 63 + versionName = '1.28.1' vectorDrawables.useSupportLibrary = true multiDexEnabled true testInstrumentationRunner 'com.instructure.teacher.ui.espresso.TeacherHiltTestRunner' @@ -111,6 +111,15 @@ android { heapEnabled = true } } + + debugMinify { + initWith debug + debuggable false + minifyEnabled true + shrinkResources true + matchingFallbacks = ['debug'] + } + release { minifyEnabled true shrinkResources true @@ -311,6 +320,8 @@ dependencies { implementation Libs.ROOM_COROUTINES testImplementation Libs.HAMCREST + + androidTestImplementation Libs.COMPOSE_UI_TEST } apply plugin: 'com.google.gms.google-services' diff --git a/apps/teacher/flank.yml b/apps/teacher/flank.yml index 97639ae19b..eafa4e03e4 100644 --- a/apps/teacher/flank.yml +++ b/apps/teacher/flank.yml @@ -1,5 +1,8 @@ gcloud: project: delta-essence-114723 +# Use the next two lines to run locally +# app: ./build/intermediates/apk/qa/debug/teacher-qa-debug.apk +# test: ./build/intermediates/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 diff --git a/apps/teacher/flank_coverage.yml b/apps/teacher/flank_coverage.yml index c23ecac71e..3d23a78f48 100644 --- a/apps/teacher/flank_coverage.yml +++ b/apps/teacher/flank_coverage.yml @@ -19,15 +19,15 @@ gcloud: directories-to-pull: - /sdcard/ test-targets: - - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub + - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubCoverage device: - - model: NexusLowRes - version: 26 + - model: Pixel2.arm + version: 29 locale: en_US orientation: portrait flank: - testShards: 16 + testShards: 10 testRuns: 1 files-to-download: - .*\.ec$ diff --git a/apps/teacher/flank_e2e_coverage.yml b/apps/teacher/flank_e2e_coverage.yml index eea5ec934e..af3fe15179 100644 --- a/apps/teacher/flank_e2e_coverage.yml +++ b/apps/teacher/flank_e2e_coverage.yml @@ -20,10 +20,10 @@ gcloud: - /sdcard/ test-targets: - annotation com.instructure.canvas.espresso.E2E - - notAnnotation com.instructure.canvas.espresso.Stub + - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubCoverage device: - - model: Nexus6P - version: 26 + - model: Pixel2.arm + version: 29 locale: en_US orientation: portrait diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssigneeListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssigneeListPageTest.kt index 2c84903a1b..925dede26e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssigneeListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssigneeListPageTest.kt @@ -87,7 +87,7 @@ class AssigneeListPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) val token = data.tokenFor(teacher)!! tokenLogin(data.domain, token, teacher) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt index fa1f934da9..33fb1837ee 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt @@ -15,7 +15,11 @@ */ package com.instructure.teacher.ui -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.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addSubmissionForAssignment +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvas.espresso.mockCanvas.utils.Randomizer import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Assignment.SubmissionType @@ -28,7 +32,6 @@ import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -38,45 +41,39 @@ import org.junit.Test class AssignmentDetailsPageTest : TeacherTest() { @Test - @TestRail(ID = "C3109579") override fun displaysPageObjects() { getToAssignmentDetailsPage( - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), + submissionTypes = listOf(ONLINE_TEXT_ENTRY), students = 1, withSubmission = true) assignmentDetailsPage.assertPageObjects() } @Test - @TestRail(ID = "C3109579") fun displaysCorrectDetails() { val assignment = getToAssignmentDetailsPage() assignmentDetailsPage.assertAssignmentDetails(assignment) } @Test - @TestRail(ID = "C3109579") fun displaysInstructions() { getToAssignmentDetailsPage(withDescription = true) assignmentDetailsPage.assertDisplaysInstructions() } @Test - @TestRail(ID = "C3134480") fun displaysNoInstructionsMessage() { getToAssignmentDetailsPage() assignmentDetailsPage.assertDisplaysNoInstructionsView() } @Test - @TestRail(ID = "C3134481") fun displaysClosedAvailability() { getToAssignmentDetailsPage(lockAt = 7.days.ago.iso8601) assignmentDetailsPage.assertAssignmentClosed() } @Test - @TestRail(ID = "C3134482") fun displaysNoFromDate() { val lockAt = 7.days.fromNow.iso8601 getToAssignmentDetailsPage(lockAt = lockAt) @@ -84,7 +81,6 @@ class AssignmentDetailsPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3134483") fun displaysNoToDate() { getToAssignmentDetailsPage(unlockAt = 7.days.ago.iso8601) assignmentDetailsPage.assertFromFilledAndToEmpty() @@ -155,7 +151,7 @@ class AssignmentDetailsPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = if(submissionTypes.isEmpty()) SubmissionType.ONLINE_TEXT_ENTRY else submissionTypes.first(), + submissionTypeList = submissionTypes.ifEmpty { listOf(ONLINE_TEXT_ENTRY) }, lockAt = lockAt, unlockAt = unlockAt, description = if(withDescription) Randomizer.randomCourseDescription() else "", diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt index 857ed54e7e..832ffce0e6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt @@ -25,7 +25,6 @@ import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -35,28 +34,24 @@ import org.junit.Test class AssignmentDueDatesPageTest : TeacherTest() { @Test - @TestRail(ID = "C3134131") override fun displaysPageObjects() { getToDueDatesPage() assignmentDueDatesPage.assertPageObjects() } @Test - @TestRail(ID = "C3134484") fun displaysNoDueDate() { getToDueDatesPage() assignmentDueDatesPage.assertDisplaysNoDueDate() } @Test - @TestRail(ID = "C3134485") fun displaysSingleDueDate() { getToDueDatesPage(dueAt = 7.days.fromNow.iso8601) assignmentDueDatesPage.assertDisplaysSingleDueDate() } @Test - @TestRail(ID = "C3134486") fun displaysAvailabilityDates() { getToDueDatesPage(lockAt = 7.days.fromNow.iso8601, unlockAt = 7.days.ago.iso8601) assignmentDueDatesPage.assertDisplaysAvailabilityDates() @@ -77,7 +72,7 @@ class AssignmentDueDatesPageTest : TeacherTest() { dueAt = dueAt, lockAt = lockAt, unlockAt = unlockAt, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) val token = data.tokenFor(teacher)!! diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt index e65087514b..aed434808d 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt @@ -22,7 +22,6 @@ import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.AssignmentGroup import com.instructure.canvasapi2.models.CanvasContextPermission -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -32,28 +31,24 @@ import org.junit.Test class AssignmentListPageTest : TeacherTest() { @Test - @TestRail(ID = "C3109578") override fun displaysPageObjects() { getToAssignmentsPage() assignmentListPage.assertPageObjects() } @Test - @TestRail(ID = "C3134487") fun displaysNoAssignmentsView() { getToAssignmentsPage(0) assignmentListPage.assertDisplaysNoAssignmentsView() } @Test - @TestRail(ID = "C3109578") fun displaysAssignment() { val assignment = getToAssignmentsPage().assignments.values.first() assignmentListPage.assertHasAssignment(assignment) } @Test - @TestRail(ID = "C3134488") fun displaysGradingPeriods() { getToAssignmentsPage(gradingPeriods = true) assignmentListPage.assertHasGradingPeriods() @@ -89,7 +84,7 @@ class AssignmentListPageTest : TeacherTest() { repeat(assignments) { data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), assignmentGroupId = assignmentGroup.id) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentSubmissionListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentSubmissionListPageTest.kt index 271ce63154..09f2b53493 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentSubmissionListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentSubmissionListPageTest.kt @@ -15,7 +15,11 @@ */ package com.instructure.teacher.ui -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.addCoursePermissions +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.CanvasContextPermission import com.instructure.dataseeding.util.ago @@ -132,7 +136,7 @@ class AssignmentSubmissionListPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = dueAt ) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CommentLibraryPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CommentLibraryPageTest.kt index 7832106162..3daf68f585 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CommentLibraryPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CommentLibraryPageTest.kt @@ -255,7 +255,7 @@ class CommentLibraryPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) data.addSubmissionForAssignment( diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseBrowserPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseBrowserPageTest.kt index d79890cb61..23fab0637c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseBrowserPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseBrowserPageTest.kt @@ -17,7 +17,6 @@ package com.instructure.teacher.ui import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.init -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -27,7 +26,6 @@ import org.junit.Test class CourseBrowserPageTest : TeacherTest() { @Test - @TestRail(ID = "C3108909") override fun displaysPageObjects() { val data = MockCanvas.init(teacherCount = 1, courseCount = 3, favoriteCourseCount = 3) val teacher = data.teachers[0] diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseSettingsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseSettingsPageTest.kt index 03a0ce089a..293c165756 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseSettingsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseSettingsPageTest.kt @@ -17,7 +17,6 @@ package com.instructure.teacher.ui import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.init -import com.instructure.espresso.TestRail import com.instructure.espresso.randomString import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin @@ -28,14 +27,12 @@ import org.junit.Test class CourseSettingsPageTest : TeacherTest() { @Test - @TestRail(ID = "C3108914") override fun displaysPageObjects() { navigateToCourseSettings() courseSettingsPage.assertPageObjects() } @Test - @TestRail(ID = "C3108915") fun editCourseName() { navigateToCourseSettings() courseSettingsPage.clickCourseName() @@ -45,7 +42,6 @@ class CourseSettingsPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3108916") fun editCourseHomePage() { navigateToCourseSettings() courseSettingsPage.clickSetHomePage() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DashboardPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DashboardPageTest.kt index e7c8834be9..ed8c118f6c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DashboardPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DashboardPageTest.kt @@ -19,7 +19,6 @@ package com.instructure.teacher.ui import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.init -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -29,7 +28,6 @@ import org.junit.Test class DashboardPageTest : TeacherTest() { @Test - @TestRail(ID = "C3108898") override fun displaysPageObjects() { val data = MockCanvas.init(teacherCount = 1, courseCount = 1, favoriteCourseCount = 1) val teacher = data.teachers[0] @@ -39,7 +37,6 @@ class DashboardPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3109494") fun displaysNoCoursesView() { val data = MockCanvas.init(teacherCount = 1, pastCourseCount = 1) val teacher = data.teachers[0] @@ -49,7 +46,6 @@ class DashboardPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3108898") fun displaysCourseList() { val data = MockCanvas.init(teacherCount = 1, favoriteCourseCount = 3, courseCount = 3) val teacher = data.teachers[0] diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt index dd07dd63c1..8b529305da 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt @@ -25,7 +25,6 @@ import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContextPermission import com.instructure.canvasapi2.utils.NumberHelper -import com.instructure.espresso.TestRail import com.instructure.espresso.randomDouble import com.instructure.espresso.randomString import com.instructure.teacher.R @@ -39,7 +38,6 @@ import org.junit.Test class EditAssignmentDetailsPageTest : TeacherTest() { @Test - @TestRail(ID = "C3109580") override fun displaysPageObjects() { getToEditAssignmentDetailsPage() editAssignmentDetailsPage.assertPageObjects() @@ -55,7 +53,6 @@ class EditAssignmentDetailsPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3134126") fun editAssignmentName() { getToEditAssignmentDetailsPage() editAssignmentDetailsPage.clickAssignmentNameEditText() @@ -66,7 +63,6 @@ class EditAssignmentDetailsPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3134126") fun editAssignmentPoints() { getToEditAssignmentDetailsPage() editAssignmentDetailsPage.clickPointsPossibleEditText() @@ -201,14 +197,12 @@ class EditAssignmentDetailsPageTest : TeacherTest() { CanvasContextPermission() // Just need to have some sort of permissions object registered ) - val assignment = data.addAssignment( courseId = course.id, withDescription = withDescription, lockAt = lockAt, unlockAt = unlockAt, - submissionType = submissionTypes.firstOrNull() - ?: Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = submissionTypes.ifEmpty { listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) } ) val token = data.tokenFor(teacher)!! diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditDashboardPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditDashboardPageTest.kt index 6ac25bf8cc..f39d3e15aa 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditDashboardPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditDashboardPageTest.kt @@ -19,7 +19,6 @@ package com.instructure.teacher.ui import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.init -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -29,7 +28,6 @@ import org.junit.Test class EditDashboardPageTest : TeacherTest() { @Test - @TestRail(ID = "C3109572") override fun displaysPageObjects() { setUpAndSignIn(numCourses = 1, numFavoriteCourses = 0) dashboardPage.clickEditDashboard() @@ -37,7 +35,6 @@ class EditDashboardPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3109572") fun displaysCourseList() { val data = setUpAndSignIn(numCourses = 1, numFavoriteCourses = 0) val course = data.courses.values.toList()[0] @@ -46,7 +43,6 @@ class EditDashboardPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3109574") fun addCourseToFavourites() { val data = setUpAndSignIn(numCourses = 1, numFavoriteCourses = 0) val courses = data.courses.values.toList() @@ -57,7 +53,6 @@ class EditDashboardPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3109575") fun removeCourseFromFavourites() { val data = setUpAndSignIn(numCourses = 1, numFavoriteCourses = 1) val courses = data.courses.values.toList() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditSyllabusPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditSyllabusPageTest.kt index ba7f29a88c..06aa6ac6d1 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditSyllabusPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditSyllabusPageTest.kt @@ -90,7 +90,7 @@ class EditSyllabusPageTest : TeacherTest() { data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 2.days.fromNow.iso8601, name = "Assignment: 1" ) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/InAppUpdatePageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/InAppUpdatePageTest.kt index 781de656c7..dcf6a78b7e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/InAppUpdatePageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/InAppUpdatePageTest.kt @@ -238,7 +238,6 @@ class InAppUpdatePageTest : TeacherTest() { } @Test - @Stub("Stubbed because on API lvl 29 device the notification will remain opened even though we push the back button at the end. Should be investigated and make some workaround once.") fun showNotificationOnFlexibleDownloadFinish() { updatePrefs.clearPrefs() val expectedTitle = context.getString(R.string.appUpdateReadyTitle) @@ -270,8 +269,8 @@ class InAppUpdatePageTest : TeacherTest() { } @Test - @Stub(description = "https://instructure.atlassian.net/browse/MBL-16824") fun flexibleUpdateCompletesIfAppRestarts() { + updatePrefs.clearPrefs() with(appUpdateManager) { setUpdateAvailable(400) setUpdatePriority(2) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginFindSchoolPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginFindSchoolPageTest.kt index 0b580f2304..48bdaf59c6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginFindSchoolPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginFindSchoolPageTest.kt @@ -1,6 +1,5 @@ package com.instructure.teacher.ui -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @@ -9,7 +8,6 @@ import org.junit.Test class LoginFindSchoolPageTest: TeacherTest() { @Test - @TestRail(ID = "C3108892") override fun displaysPageObjects() { loginLandingPage.clickFindMySchoolButton() loginFindSchoolPage.assertPageObjects() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginLandingPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginLandingPageTest.kt index 2b2e096153..ec1382b24c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginLandingPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginLandingPageTest.kt @@ -1,6 +1,5 @@ package com.instructure.teacher.ui -import com.instructure.espresso.TestRail import com.instructure.espresso.filters.P1 import com.instructure.teacher.ui.utils.TeacherTest import dagger.hilt.android.testing.HiltAndroidTest @@ -11,7 +10,6 @@ class LoginLandingPageTest: TeacherTest() { // Runs live; no MockCanvas @Test - @TestRail(ID = "C3108891") @P1 override fun displaysPageObjects() { loginLandingPage.assertPageObjects() @@ -19,7 +17,6 @@ class LoginLandingPageTest: TeacherTest() { // Runs live; no MockCanvas @Test - @TestRail(ID = "C3108893") fun opensCanvasNetworksSignInPage() { loginLandingPage.clickCanvasNetworkButton() loginSignInPage.assertPageObjects() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginSignInPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginSignInPageTest.kt index 898252fea7..2bdbcdd5d0 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginSignInPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginSignInPageTest.kt @@ -17,7 +17,6 @@ package com.instructure.teacher.ui -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.enterDomain import dagger.hilt.android.testing.HiltAndroidTest @@ -28,7 +27,6 @@ class LoginSignInPageTest: TeacherTest() { // Runs live; no MockCanvas @Test - @TestRail(ID = "C3108896") override fun displaysPageObjects() { loginLandingPage.clickFindMySchoolButton() enterDomain() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt new file mode 100644 index 0000000000..aa902d12ad --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt @@ -0,0 +1,423 @@ +/* + * Copyright (C) 2024 - 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.teacher.ui + +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addFileToCourse +import com.instructure.canvas.espresso.mockCanvas.addItemToModule +import com.instructure.canvas.espresso.mockCanvas.addModuleToCourse +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CanvasContextPermission +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.Tab +import com.instructure.dataseeding.util.Randomizer +import com.instructure.teacher.R +import com.instructure.teacher.ui.utils.TeacherComposeTest +import com.instructure.teacher.ui.utils.TeacherTest +import com.instructure.teacher.ui.utils.openOverflowMenu +import com.instructure.teacher.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@HiltAndroidTest +class ModuleListPageTest : TeacherComposeTest() { + + @Test + override fun displaysPageObjects() { + goToModulesPage() + moduleListPage.assertPageObjects() + } + + @Test + fun assertDisplaysMenuItems() { + goToModulesPage() + openOverflowMenu() + moduleListPage.assertToolbarMenuItems() + } + + @Test + fun assertDisplaysModuleMenuItems() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + + moduleListPage.clickItemOverflow(module.name.orEmpty()) + moduleListPage.assertModuleMenuItems() + } + + @Test + fun assertPublishedItemActions() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val assignment = data.addAssignment( + courseId = course.id, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + ) + + data.addItemToModule(data.courses.values.first(), module.id, assignment, published = true) + + moduleListPage.refresh() + + moduleListPage.clickItemOverflow(assignment.name.orEmpty()) + moduleListPage.assertOverflowItem(R.string.unpublish) + } + + @Test + fun assertUnpublishedItemActions() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val assignment = data.addAssignment( + courseId = course.id, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + ) + + data.addItemToModule(data.courses.values.first(), module.id, assignment, published = false) + + moduleListPage.refresh() + + moduleListPage.clickItemOverflow(assignment.name.orEmpty()) + moduleListPage.assertOverflowItem(R.string.publish) + } + + @Test + fun assertFileEditOpens() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val fileId = data.addFileToCourse(course.id) + val rootFolderId = data.courseRootFolders[course.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + data.addItemToModule( + course = course, + moduleId = module.id, + item = fileFolder!! + ) + + moduleListPage.refresh() + + moduleListPage.clickItemOverflow(fileFolder.displayName.orEmpty()) + + moduleListPage.assertFileEditDialogVisible() + } + + @Test + fun publishModuleItem() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val assignment = data.addAssignment( + courseId = course.id, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + ) + + data.addItemToModule(data.courses.values.first(), module.id, assignment, published = false) + + moduleListPage.refresh() + + moduleListPage.clickItemOverflow(assignment.name.orEmpty()) + moduleListPage.clickOnText(R.string.publishModuleItemAction) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + moduleListPage.assertSnackbarText(R.string.moduleItemPublished) + moduleListPage.assertModuleItemIsPublished(assignment.name.orEmpty()) + } + + @Test + fun unpublishModuleItem() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val assignment = data.addAssignment( + courseId = course.id, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + ) + + data.addItemToModule(data.courses.values.first(), module.id, assignment, published = true) + + moduleListPage.refresh() + + moduleListPage.clickItemOverflow(assignment.name.orEmpty()) + moduleListPage.clickOnText(R.string.unpublishModuleItemAction) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + moduleListPage.assertSnackbarText(R.string.moduleItemUnpublished) + moduleListPage.assertModuleItemNotPublished(assignment.name.orEmpty()) + } + + @Test + fun publishModuleOnly() { + val data = goToModulesPage(publishedModuleCount = 0, unpublishedModuleCount = 1) + val unpublishedModule = data.courseModules.values.first().first { it.published == false } + val assignment = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), unpublishedModule.id, assignment, published = false) + moduleListPage.refresh() + + moduleListPage.clickItemOverflow(unpublishedModule.name.orEmpty()) + moduleListPage.clickOnText(R.string.publishModuleOnly) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + moduleListPage.assertSnackbarText(R.string.onlyModulePublished) + moduleListPage.assertModuleIsPublished(unpublishedModule.name.orEmpty()) + moduleListPage.assertModuleItemNotPublished(assignment.name.orEmpty()) + } + + @Test + fun publishModuleAndItems() { + val data = goToModulesPage(publishedModuleCount = 0, unpublishedModuleCount = 1) + val unpublishedModule = data.courseModules.values.first().first { it.published == false } + val assignment = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), unpublishedModule.id, assignment, published = false) + moduleListPage.refresh() + + moduleListPage.clickItemOverflow(unpublishedModule.name.orEmpty()) + moduleListPage.clickOnText(R.string.publishModuleAndItems) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + progressPage.clickDone() + + moduleListPage.assertSnackbarText(R.string.moduleAndAllItemsPublished) + moduleListPage.assertModuleIsPublished(unpublishedModule.name.orEmpty()) + moduleListPage.assertModuleItemIsPublished(assignment.name.orEmpty()) + } + + @Test + fun unpublishModuleAndItems() { + val data = goToModulesPage(publishedModuleCount = 1, unpublishedModuleCount = 0) + val publishedModule = data.courseModules.values.first().first { it.published == true } + val assignment = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), publishedModule.id, assignment, published = true) + moduleListPage.refresh() + + moduleListPage.clickItemOverflow(publishedModule.name.orEmpty()) + moduleListPage.clickOnText(R.string.unpublishModuleAndItems) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + progressPage.clickDone() + + moduleListPage.assertSnackbarText(R.string.moduleAndAllItemsUnpublished) + moduleListPage.assertModuleNotPublished(publishedModule.name.orEmpty()) + moduleListPage.assertModuleItemNotPublished(assignment.name.orEmpty()) + } + + @Test + fun publishModulesOnly() { + val data = goToModulesPage(publishedModuleCount = 0, unpublishedModuleCount = 2) + val unpublishedModules = data.courseModules.values.first().filter { it.published == false } + val assignment1 = data.addAssignment(courseId = data.courses.values.first().id) + val assignment2 = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), unpublishedModules[0].id, assignment1, published = false) + data.addItemToModule(data.courses.values.first(), unpublishedModules[1].id, assignment2, published = false) + + moduleListPage.refresh() + + openOverflowMenu() + moduleListPage.clickOnText(R.string.publishModulesOnly) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + progressPage.clickDone() + + moduleListPage.assertSnackbarText(R.string.onlyModulesPublished) + moduleListPage.assertModuleIsPublished(unpublishedModules[0].name.orEmpty()) + moduleListPage.assertModuleIsPublished(unpublishedModules[1].name.orEmpty()) + moduleListPage.assertModuleItemNotPublished(assignment1.name.orEmpty()) + moduleListPage.assertModuleItemNotPublished(assignment2.name.orEmpty()) + } + + @Test + fun publishModulesAndItems() { + val data = goToModulesPage(publishedModuleCount = 0, unpublishedModuleCount = 2) + val unpublishedModules = data.courseModules.values.first().filter { it.published == false } + val assignment1 = data.addAssignment(courseId = data.courses.values.first().id) + val assignment2 = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), unpublishedModules[0].id, assignment1, published = false) + data.addItemToModule(data.courses.values.first(), unpublishedModules[1].id, assignment2, published = false) + + moduleListPage.refresh() + + openOverflowMenu() + moduleListPage.clickOnText(R.string.publishAllModulesAndItems) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + progressPage.clickDone() + + moduleListPage.assertSnackbarText(R.string.allModulesAndAllItemsPublished) + moduleListPage.assertModuleIsPublished(unpublishedModules[0].name.orEmpty()) + moduleListPage.assertModuleIsPublished(unpublishedModules[1].name.orEmpty()) + moduleListPage.assertModuleItemIsPublished(assignment1.name.orEmpty()) + moduleListPage.assertModuleItemIsPublished(assignment2.name.orEmpty()) + } + + @Test + fun unpublishModulesAndItems() { + val data = goToModulesPage(publishedModuleCount = 2, unpublishedModuleCount = 0) + val unpublishedModules = data.courseModules.values.first().filter { it.published == true } + val assignment1 = data.addAssignment(courseId = data.courses.values.first().id) + val assignment2 = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), unpublishedModules[0].id, assignment1, published = true) + data.addItemToModule(data.courses.values.first(), unpublishedModules[1].id, assignment2, published = true) + + moduleListPage.refresh() + + openOverflowMenu() + moduleListPage.clickOnText(R.string.unpublishAllModulesAndItems) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + progressPage.clickDone() + + moduleListPage.assertSnackbarText(R.string.allModulesAndAllItemsUnpublished) + moduleListPage.assertModuleNotPublished(unpublishedModules[0].name.orEmpty()) + moduleListPage.assertModuleNotPublished(unpublishedModules[1].name.orEmpty()) + moduleListPage.assertModuleItemNotPublished(assignment1.name.orEmpty()) + moduleListPage.assertModuleItemNotPublished(assignment2.name.orEmpty()) + } + + @Test + fun unpublishFileModuleItem() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val fileId = data.addFileToCourse(course.id) + val rootFolderId = data.courseRootFolders[course.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + data.addItemToModule( + course = course, + moduleId = module.id, + item = fileFolder!!, + contentId = fileId, + published = true, + moduleContentDetails = ModuleContentDetails( + hidden = false, + locked = false + ) + ) + + moduleListPage.refresh() + + moduleListPage.clickItemOverflow(fileFolder.displayName.orEmpty()) + + updateFilePermissionsPage.swipeUpBottomSheet() + updateFilePermissionsPage.clickUnpublishRadioButton() + updateFilePermissionsPage.clickSaveButton() + + moduleListPage.assertModuleItemNotPublished(fileFolder.displayName.orEmpty()) + } + + @Test + fun publishFileModuleItem() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val fileId = data.addFileToCourse(course.id) + val rootFolderId = data.courseRootFolders[course.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + data.addItemToModule( + course = course, + moduleId = module.id, + item = fileFolder!!, + contentId = fileId, + published = false, + moduleContentDetails = ModuleContentDetails( + hidden = false, + locked = true + ) + ) + + moduleListPage.refresh() + + moduleListPage.clickItemOverflow(fileFolder.displayName.orEmpty()) + + updateFilePermissionsPage.swipeUpBottomSheet() + updateFilePermissionsPage.clickPublishRadioButton() + updateFilePermissionsPage.clickSaveButton() + + moduleListPage.assertModuleItemIsPublished(fileFolder.displayName.orEmpty()) + } + + @Test + fun hideFileModuleItem() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val fileId = data.addFileToCourse(course.id) + val rootFolderId = data.courseRootFolders[course.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + data.addItemToModule( + course = course, + moduleId = module.id, + item = fileFolder!!, + contentId = fileId, + published = true, + moduleContentDetails = ModuleContentDetails( + hidden = false, + locked = false + ) + ) + + moduleListPage.refresh() + + moduleListPage.clickItemOverflow(fileFolder.displayName.orEmpty()) + + updateFilePermissionsPage.swipeUpBottomSheet() + updateFilePermissionsPage.clickHideRadioButton() + updateFilePermissionsPage.clickSaveButton() + + moduleListPage.assertModuleItemHidden(fileFolder.displayName.orEmpty()) + } + + private fun goToModulesPage(publishedModuleCount: Int = 1, unpublishedModuleCount: Int = 0): MockCanvas { + val data = MockCanvas.init(teacherCount = 1, courseCount = 1, favoriteCourseCount = 1) + val course = data.courses.values.first() + + data.addCoursePermissions( + course.id, + CanvasContextPermission() // Just need to have some sort of permissions object registered + ) + + val modulesTab = Tab(position = 2, label = "Modules", visibility = "public", tabId = Tab.MODULES_ID) + data.courseTabs[course.id]!! += modulesTab + + repeat(publishedModuleCount) { data.addModuleToCourse(course, Randomizer.randomModuleName(), published = true) } + repeat(unpublishedModuleCount) { + data.addModuleToCourse( + course, + Randomizer.randomModuleName(), + published = false + ) + } + + val teacher = data.teachers.first() + val token = data.tokenFor(teacher)!! + tokenLogin(data.domain, token, teacher) + + dashboardPage.openCourse(course) + courseBrowserPage.openModulesTab() + return data + } + +} \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizDetailsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizDetailsPageTest.kt index 859e63e851..d6177861e7 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizDetailsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizDetailsPageTest.kt @@ -15,14 +15,17 @@ */ package com.instructure.teacher.ui -import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addQuizSubmission +import com.instructure.canvas.espresso.mockCanvas.addQuizToCourse +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.CanvasContextPermission import com.instructure.canvasapi2.models.Quiz import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -32,42 +35,36 @@ import org.junit.Test class QuizDetailsPageTest: TeacherTest() { @Test - @TestRail(ID = "C3109579") override fun displaysPageObjects() { getToQuizDetailsPage() quizDetailsPage.assertPageObjects() } @Test - @TestRail(ID = "C3109579") fun displaysCorrectDetails() { val quiz = getToQuizDetailsPage() quizDetailsPage.assertQuizDetails(quiz) } @Test - @TestRail(ID = "C3109579") fun displaysInstructions() { getToQuizDetailsPage(withDescription = true) quizDetailsPage.assertDisplaysInstructions() } @Test - @TestRail(ID = "C3134480") fun displaysNoInstructionsMessage() { getToQuizDetailsPage() quizDetailsPage.assertDisplaysNoInstructionsView() } @Test - @TestRail(ID = "C3134481") fun displaysClosedAvailability() { getToQuizDetailsPage(lockAt = 1.days.ago.iso8601) quizDetailsPage.assertQuizClosed() } @Test - @TestRail(ID = "C3134482") fun displaysNoFromDate() { val lockAt = 2.days.fromNow.iso8601 getToQuizDetailsPage(lockAt = lockAt) @@ -75,7 +72,6 @@ class QuizDetailsPageTest: TeacherTest() { } @Test - @TestRail(ID = "C3134483") fun displaysNoToDate() { getToQuizDetailsPage(unlockAt = 2.days.ago.iso8601) quizDetailsPage.assertFromFilledAndToEmpty() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderCommentsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderCommentsPageTest.kt index f479a552ab..cfaefe31a2 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderCommentsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderCommentsPageTest.kt @@ -15,8 +15,16 @@ */ package com.instructure.teacher.ui -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.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addSubmissionsForAssignment +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.Attachment +import com.instructure.canvasapi2.models.CanvasContextPermission +import com.instructure.canvasapi2.models.Submission +import com.instructure.canvasapi2.models.SubmissionComment import com.instructure.espresso.randomString import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin @@ -38,7 +46,7 @@ class SpeedGraderCommentsPageTest : TeacherTest() { @Test override fun displaysPageObjects() { goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) speedGraderCommentsPage.assertPageObjects() @@ -46,42 +54,42 @@ class SpeedGraderCommentsPageTest : TeacherTest() { @Test fun displaysAuthorName() { - val submission = goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + val submissionList = goToSpeedGraderCommentsPage( + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), withComment = true ) - val authorName = submission!!.submissionComments[0].authorName!! + val authorName = submissionList?.get(0)!!.submissionComments[0].authorName!! speedGraderCommentsPage.assertDisplaysAuthorName(authorName) } @Test fun displaysCommentText() { - val submission = goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + val submissionList = goToSpeedGraderCommentsPage( + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), withComment = true ) - val commentText = submission!!.submissionComments[0].comment!! + val commentText = submissionList?.get(0)!!.submissionComments[0].comment!! speedGraderCommentsPage.assertDisplaysCommentText(commentText) } @Test fun displaysCommentAttachment() { - val submission = goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + val submissionList = goToSpeedGraderCommentsPage( + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), withComment = true, attachment = attachment ) - val attachment = submission!!.submissionComments[0].attachments.get(0) + val attachment = submissionList?.get(0)!!.submissionComments[0].attachments.get(0) speedGraderCommentsPage.assertDisplaysCommentAttachment(attachment) } @Test fun displaysSubmissionHistory() { goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) speedGraderCommentsPage.assertDisplaysSubmission() @@ -89,19 +97,19 @@ class SpeedGraderCommentsPageTest : TeacherTest() { @Test fun displaysSubmissionFile() { - val submission = goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_UPLOAD, + val submissionList = goToSpeedGraderCommentsPage( + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD), attachment = attachment ) - val fileAttachments = submission!!.attachments.get(0) + val fileAttachments = submissionList?.get(0)!!.attachments[0] speedGraderCommentsPage.assertDisplaysSubmissionFile(fileAttachments) } @Test fun addsNewTextComment() { goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) val newComment = randomString(32) @@ -113,7 +121,7 @@ class SpeedGraderCommentsPageTest : TeacherTest() { fun showsNoCommentsMessage() { goToSpeedGraderCommentsPage( submissionCount = 0, - submissionType = Assignment.SubmissionType.ON_PAPER + submissionTypeList = listOf(Assignment.SubmissionType.ON_PAPER) ) speedGraderCommentsPage.assertDisplaysEmptyState() @@ -123,17 +131,17 @@ class SpeedGraderCommentsPageTest : TeacherTest() { * Common setup routine * * [submissionCount] is the number of submissions for the created assignment. Typically 0 or 1. - * [submissionType] is the submission type for the assignment. + * [submissionTypeList] is the submission type for the assignment. * [withComment] if true, include a (student) comment with the submission. * [attachment] if non-null, is either a comment attachment (if withComment is true) or a submission * attachment (if withComment is false). * */ private fun goToSpeedGraderCommentsPage( - submissionCount: Int = 1, - submissionType: Assignment.SubmissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, - withComment: Boolean = false, - attachment: Attachment? = null): Submission? { + submissionCount: Int = 1, + submissionTypeList: List = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), + withComment: Boolean = false, + attachment: Attachment? = null): MutableList? { val data = MockCanvas.init(teacherCount = 1, studentCount = 1, courseCount = 1, favoriteCourseCount = 1) val teacher = data.teachers[0] @@ -147,7 +155,7 @@ class SpeedGraderCommentsPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = submissionType + submissionTypeList = submissionTypeList ) var submissionComment : SubmissionComment? = null @@ -163,12 +171,13 @@ class SpeedGraderCommentsPageTest : TeacherTest() { ) } - var submission: Submission? = null + var submissionList = mutableListOf() repeat(submissionCount) { - submission = data.addSubmissionForAssignment( + val submissionTypesRaw = submissionTypeList.map { it.apiString } + submissionList = data.addSubmissionsForAssignment( assignmentId = assignment.id, userId = student.id, - type = submissionType.apiString, + types = submissionTypesRaw, comment = submissionComment, attachment = if (withComment) null else attachment ) @@ -185,6 +194,6 @@ class SpeedGraderCommentsPageTest : TeacherTest() { speedGraderPage.selectCommentsTab() speedGraderPage.swipeUpCommentsTab() - return submission + return submissionList } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderFilesPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderFilesPageTest.kt index 929c21223e..f53301a282 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderFilesPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderFilesPageTest.kt @@ -15,7 +15,11 @@ */ package com.instructure.teacher.ui -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.addCoursePermissions +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.Attachment import com.instructure.canvasapi2.models.CanvasContextPermission @@ -76,7 +80,7 @@ class SpeedGraderFilesPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_UPLOAD + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD) ) repeat(submissionCount) { diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderGradePageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderGradePageTest.kt index 5aa322958a..f743f88297 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderGradePageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderGradePageTest.kt @@ -187,7 +187,7 @@ class SpeedGraderGradePageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = pointsPossible, gradingType = gradingType ) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderPageTest.kt index 35e39d13f7..81a03fd8b6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderPageTest.kt @@ -15,9 +15,16 @@ */ package com.instructure.teacher.ui -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.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addSubmissionsForAssignment +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.models.Assignment.SubmissionType.* +import com.instructure.canvasapi2.models.Assignment.SubmissionType.EXTERNAL_TOOL +import com.instructure.canvasapi2.models.Assignment.SubmissionType.ONLINE_TEXT_ENTRY +import com.instructure.canvasapi2.models.Assignment.SubmissionType.ONLINE_URL +import com.instructure.canvasapi2.models.Assignment.SubmissionType.ON_PAPER import com.instructure.canvasapi2.models.CanvasContextPermission import com.instructure.teacher.R import com.instructure.teacher.ui.utils.TeacherTest @@ -36,13 +43,13 @@ class SpeedGraderPageTest : TeacherTest() { @Test fun displaysSubmissionDropDown() { - goToSpeedGraderPage(submissionType = ONLINE_TEXT_ENTRY, students = 1, submissions = listOf(2)) + goToSpeedGraderPage(submissionTypeList = listOf(ONLINE_TEXT_ENTRY), students = 1, submissions = listOf(2)) speedGraderPage.assertHasSubmissionDropDown() } @Test fun opensToCorrectSubmission() { - val data = goToSpeedGraderPage(students = 4, submissionType = ONLINE_TEXT_ENTRY) + val data = goToSpeedGraderPage(students = 4, submissionTypeList = listOf(ONLINE_TEXT_ENTRY)) speedGraderPage.clickBackButton() val students = data.students for (i in 0 until students.size) { @@ -73,37 +80,37 @@ class SpeedGraderPageTest : TeacherTest() { @Test fun displaysTextSubmission() { - goToSpeedGraderPage(submissionType = ONLINE_TEXT_ENTRY, submissions = listOf(1)) + goToSpeedGraderPage(submissionTypeList = listOf(ONLINE_TEXT_ENTRY), submissions = listOf(1)) speedGraderPage.assertDisplaysTextSubmissionView() } @Test fun displaysUnsubmittedEmptyState() { - goToSpeedGraderPage(submissionType = ONLINE_TEXT_ENTRY) + goToSpeedGraderPage(submissionTypeList = listOf(ONLINE_TEXT_ENTRY)) speedGraderPage.assertDisplaysEmptyState(R.string.noSubmissionTeacher) } @Test fun displaysNoSubmissionsAllowedEmptyState() { - goToSpeedGraderPage(submissionType = Assignment.SubmissionType.NONE) + goToSpeedGraderPage(submissionTypeList = listOf(Assignment.SubmissionType.NONE)) speedGraderPage.assertDisplaysEmptyState(R.string.speedGraderNoneMessage) } @Test fun displaysOnPaperEmptyState() { - goToSpeedGraderPage(submissionType = ON_PAPER) + goToSpeedGraderPage(submissionTypeList = listOf(ON_PAPER)) speedGraderPage.assertDisplaysEmptyState(R.string.speedGraderOnPaperMessage) } @Test fun displaysExternalToolEmptyState() { - goToSpeedGraderPage(submissionType = EXTERNAL_TOOL) + goToSpeedGraderPage(submissionTypeList = listOf(EXTERNAL_TOOL)) speedGraderPage.assertDisplaysEmptyState(R.string.noSubmissionTeacher) } @Test fun displaysUrlSubmission() { - val data = goToSpeedGraderPage(submissionType = ONLINE_URL, submissions = listOf(1)) + val data = goToSpeedGraderPage(submissionTypeList = listOf(ONLINE_URL), submissions = listOf(1)) val assignment = data.assignments.values.first() val submission = data.submissions[assignment.id]!!.first() speedGraderPage.assertDisplaysUrlSubmissionLink(submission) @@ -111,10 +118,10 @@ class SpeedGraderPageTest : TeacherTest() { } private fun goToSpeedGraderPage( - students: Int = 1, - submissionType: Assignment.SubmissionType = Assignment.SubmissionType.NONE, - submissions: List = listOf(0), - selectStudent: Int = 0 + students: Int = 1, + submissionTypeList: List = listOf(Assignment.SubmissionType.NONE), + submissions: List = listOf(0), + selectStudent: Int = 0 ): MockCanvas { val data = MockCanvas.init(teacherCount = 1, studentCount = students, courseCount = 1, favoriteCourseCount = 1) val teacher = data.teachers[0] @@ -127,24 +134,24 @@ class SpeedGraderPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = submissionType + submissionTypeList = submissionTypeList ) - val assignmentSubmissions = - (0 until submissions.size).map { - if(students < it + 1) throw Exception("student count does not agree with submissions") - val student = data.students[it] - val submissionCount = submissions[it] - repeat(submissionCount) { index -> - data.addSubmissionForAssignment( - assignmentId = assignment.id, - userId = student.id, - type = submissionType.apiString, - body = if(submissionType == Assignment.SubmissionType.ONLINE_URL) null else "AssignmentBody $index", - url = if(submissionType == Assignment.SubmissionType.ONLINE_URL) "www.google.com" else null - ) - } - } + (0 until submissions.size).map { + if(students < it + 1) throw Exception("student count does not agree with submissions") + val student = data.students[it] + val submissionCount = submissions[it] + val submissionTypesRaw = submissionTypeList.map { it.apiString } + repeat(submissionCount) { index -> + data.addSubmissionsForAssignment( + assignmentId = assignment.id, + userId = student.id, + types = submissionTypesRaw, + body = if(submissionTypesRaw.contains(Assignment.SubmissionType.ONLINE_URL.apiString)) null else "AssignmentBody $index", + url = if(submissionTypesRaw.contains(Assignment.SubmissionType.ONLINE_URL.apiString)) "www.google.com" else null + ) + } + } val token = data.tokenFor(teacher)!! tokenLogin(data.domain, token, teacher) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SyllabusPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SyllabusPageTest.kt index 50dc6a2d82..5cf04e871d 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SyllabusPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SyllabusPageTest.kt @@ -16,7 +16,12 @@ */ package com.instructure.teacher.ui -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.addCourseCalendarEvent +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addCourseSettings +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContextPermission import com.instructure.canvasapi2.models.CourseSettings @@ -101,7 +106,7 @@ class SyllabusPageTest : TeacherTest() { repeat(assignmentCount) { data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 2.days.fromNow.iso8601, name = "Assignment: $it" ) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt new file mode 100644 index 0000000000..3fb6f84c2e --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2024 - 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.teacher.ui + +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addFileToCourse +import com.instructure.canvas.espresso.mockCanvas.addItemToModule +import com.instructure.canvas.espresso.mockCanvas.addModuleToCourse +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.CanvasContextPermission +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.Tab +import com.instructure.canvasapi2.utils.toApiString +import com.instructure.dataseeding.util.Randomizer +import com.instructure.teacher.ui.utils.TeacherTest +import com.instructure.teacher.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test +import java.util.Calendar +import java.util.Date + +@HiltAndroidTest +class UpdateFilePermissionsPageTest : TeacherTest() { + + override fun displaysPageObjects() = Unit + + @Test + fun assertFilePublished() { + goToPage(fileAvailability = "published") + updateFilePermissionsPage.assertFilePublished() + } + + @Test + fun assertFileUnpublished() { + goToPage(fileAvailability = "unpublished") + updateFilePermissionsPage.assertFileUnpublished() + } + + @Test + fun assertFileHidden() { + goToPage(fileAvailability = "hidden") + updateFilePermissionsPage.assertFileHidden() + } + + @Test + fun assertFileScheduled() { + val calendar = Calendar.getInstance() + val unlockDate = calendar.time + val lockDate = calendar.apply { add(Calendar.MONTH, 1) }.time + goToPage(fileAvailability = "scheduled", unlockDate = unlockDate, lockDate = lockDate) + updateFilePermissionsPage.assertFileScheduled() + } + + @Test + fun assertFileVisibilityInherit() { + goToPage(fileVisibility = "inherit", fileAvailability = "published") + updateFilePermissionsPage.assertFileVisibilityInherit() + } + + @Test + fun assertFileVisibilityContext() { + goToPage(fileVisibility = "context", fileAvailability = "published") + updateFilePermissionsPage.assertFileVisibilityContext() + } + + @Test + fun assertFileVisibilityInstitution() { + goToPage(fileVisibility = "institution", fileAvailability = "published") + updateFilePermissionsPage.assertFileVisibilityInstitution() + } + + @Test + fun assertFileVisibilityPublic() { + goToPage(fileVisibility = "public", fileAvailability = "published") + updateFilePermissionsPage.assertFileVisibilityPublic() + } + + @Test + fun assertScheduleLayoutVisible() { + val calendar = Calendar.getInstance() + val unlockDate = calendar.time + val lockDate = calendar.apply { add(Calendar.MONTH, 1) }.time + goToPage(fileAvailability = "scheduled", unlockDate = unlockDate, lockDate = lockDate) + updateFilePermissionsPage.assertScheduleLayoutDisplayed() + } + + @Test + fun assertScheduleLayoutNotVisible() { + goToPage(fileAvailability = "published") + updateFilePermissionsPage.assertScheduleLayoutNotDisplayed() + } + + @Test + fun assertUnlockDate() { + val calendar = Calendar.getInstance() + val unlockDate = calendar.time + val lockDate = calendar.apply { add(Calendar.MONTH, 1) }.time + goToPage(fileAvailability = "scheduled", unlockDate = unlockDate, lockDate = lockDate) + updateFilePermissionsPage.assertUnlockDate(unlockDate) + } + + @Test + fun assertLockDate() { + val calendar = Calendar.getInstance() + val unlockDate = calendar.time + val lockDate = calendar.apply { add(Calendar.MONTH, 1) }.time + goToPage(fileAvailability = "scheduled", unlockDate = unlockDate, lockDate = lockDate) + updateFilePermissionsPage.assertLockDate(lockDate) + } + + @Test + fun assertVisibilityDisabledIfUnpublished() { + goToPage(fileVisibility = "public", fileAvailability = "unpublished") + updateFilePermissionsPage.assertVisibilityDisabled() + } + + @Test + fun assertVisibilityEnabled() { + goToPage(fileVisibility = "public", fileAvailability = "published") + updateFilePermissionsPage.assertVisibilityEnabled() + } + + private fun goToPage(fileVisibility: String = "inherit", fileAvailability: String = "published", unlockDate: Date? = null, lockDate: Date? = null) : MockCanvas { + val data = MockCanvas.init(teacherCount = 1, courseCount = 1, favoriteCourseCount = 1) + val course = data.courses.values.first() + + data.addCoursePermissions( + course.id, + CanvasContextPermission() // Just need to have some sort of permissions object registered + ) + + val modulesTab = Tab(position = 2, label = "Modules", visibility = "public", tabId = Tab.MODULES_ID) + data.courseTabs[course.id]!! += modulesTab + + data.addModuleToCourse(course, Randomizer.randomModuleName(), published = true) + + val fileId = data.addFileToCourse(course.id, visibilityLevel = fileVisibility) + val rootFolderId = data.courseRootFolders[course.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + + val module = data.courseModules.values.first().first() + + data.addItemToModule( + course = course, + moduleId = module.id, + item = fileFolder!!, + contentId = fileId, + published = fileAvailability == "published", + moduleContentDetails = ModuleContentDetails( + hidden = fileAvailability == "hidden", + locked = fileAvailability == "unpublished", + unlockAt = unlockDate?.toApiString(), + lockAt = lockDate?.toApiString() + ) + ) + + val teacher = data.teachers.first() + val token = data.tokenFor(teacher)!! + tokenLogin(data.domain, token, teacher) + + dashboardPage.openCourse(course) + courseBrowserPage.openModulesTab() + moduleListPage.clickItemOverflow(fileFolder.name.orEmpty()) + updateFilePermissionsPage.swipeUpBottomSheet() + return data + } +} \ No newline at end of file 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 93d4fe5944..d4581c848a 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,11 +29,7 @@ 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() { @@ -44,10 +40,6 @@ class AnnouncementsE2ETest : TeacherTest() { //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 - * - */ @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ANNOUNCEMENTS, TestCategory.E2E) 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 49a4aea411..b11e0feab6 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 @@ -25,10 +25,6 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.SubmissionsApi -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.GradingType import com.instructure.dataseeding.model.SubmissionType @@ -181,8 +177,8 @@ class AssignmentE2ETest : TeacherTest() { assignmentDetailsPage.assertNotSubmitted(1,3) assignmentDetailsPage.assertNeedsGrading(2,3) - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${gradedStudent.name} student.") - gradeSubmission(teacher, course, assignment, gradedStudent) + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${gradedStudent.name}' student.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment[0].id, gradedStudent.id, postedGrade = "15") Log.d(STEP_TAG,"Refresh the page. Assert that the number of 'Graded' is increased and the number of 'Not Submitted' and 'Needs Grading' are decreased.") assignmentDetailsPage.refresh() @@ -306,16 +302,13 @@ class AssignmentE2ETest : TeacherTest() { dueAt = 1.days.fromNow.iso8601 )) - Log.d(PREPARATION_TAG,"Submit ${assignment.name} assignment for ${student.name} student.") - SubmissionsApi.seedAssignmentSubmission(SubmissionsApi.SubmissionSeedRequest( - assignmentId = assignment.id, - courseId = course.id, - studentToken = student.token, + Log.d(PREPARATION_TAG,"Submit '${assignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, assignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY )) - )) + ) Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") tokenLogin(teacher) @@ -363,7 +356,7 @@ class AssignmentE2ETest : TeacherTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG, "Seed a text assignment/file/submission.") - val assignment = createAssignment(course, teacher) + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), allowedExtensions = listOf("txt")) Log.d(PREPARATION_TAG, "Seed a text file.") val submissionUploadInfo = uploadTextFile( @@ -373,8 +366,8 @@ class AssignmentE2ETest : TeacherTest() { fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION ) - Log.d(PREPARATION_TAG, "Submit the ${assignment.name} assignment.") - submitCourseAssignment(course, assignment, submissionUploadInfo, student) + Log.d(PREPARATION_TAG, "Submit the '${assignment.name}' assignment.") + SubmissionsApi.submitCourseAssignment(course.id, student.token, assignment.id, submissionType = SubmissionType.ONLINE_UPLOAD, fileIds = mutableListOf(submissionUploadInfo.id)) Log.d(PREPARATION_TAG,"Seed a comment attachment upload.") val commentUploadInfo = uploadTextFile( @@ -384,7 +377,8 @@ class AssignmentE2ETest : TeacherTest() { fileUploadType = FileUploadType.COMMENT_ATTACHMENT ) - commentOnSubmission(student, course, assignment, commentUploadInfo) + Log.d(PREPARATION_TAG, "Comment a text file as a teacher to the '${student.name}' student's submission of the '${assignment.name}' assignment.") + SubmissionsApi.commentOnSubmission(course.id, student.token, assignment.id, fileIds = mutableListOf(commentUploadInfo.id)) Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") tokenLogin(teacher) @@ -406,64 +400,4 @@ class AssignmentE2ETest : TeacherTest() { assignmentSubmissionListPage.assertCommentAttachmentDisplayedCommon(commentUploadInfo.fileName, student.shortName) } - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignment: List, - gradedStudent: CanvasUserApiModel - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignment[0].id, - studentId = gradedStudent.id, - postedGrade = "15", - excused = false - ) - } - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = false, - submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), - allowedExtensions = listOf("txt"), - teacherToken = teacher.token - ) - ) - } - - private fun submitCourseAssignment( - course: CourseApiModel, - assignment: AssignmentApiModel, - submissionUploadInfo: AttachmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_UPLOAD, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(submissionUploadInfo.id), - studentToken = student.token - ) - } - - private fun commentOnSubmission( - student: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - commentUploadInfo: AttachmentApiModel - ) { - SubmissionsApi.commentOnSubmission( - studentToken = student.token, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(commentUploadInfo.id) - ) - } - } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CommentLibraryE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CommentLibraryE2ETest.kt index f44d8e23d7..80870d73a1 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CommentLibraryE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CommentLibraryE2ETest.kt @@ -27,7 +27,7 @@ import com.instructure.dataseeding.api.CommentLibraryApi import com.instructure.dataseeding.api.SubmissionsApi import com.instructure.dataseeding.api.UserApi import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.GradingType +import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.model.UserSettingsApiModel import com.instructure.dataseeding.util.days @@ -56,8 +56,8 @@ class CommentLibraryE2ETest : TeacherTest() { val student = data.studentsList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Preparing assignment and submit that with the student. Enable comment library in user settings.") - val testAssignment = prepareData(course.id, student.token, teacher.token, teacher.id) + Log.d(PREPARATION_TAG,"Make an assignment with a submission for the '${course.name}' course and '${student.name}' student. Set the 'Show suggestions when typing' setting to see the comment library itself.") + val testAssignment = prepareSettingsAndMakeAssignmentWithSubmission(course, student.token, teacher.token, teacher.id) Log.d(PREPARATION_TAG,"Generate comments for comment library.") val testComment = "Test Comment" @@ -76,7 +76,7 @@ class CommentLibraryE2ETest : TeacherTest() { speedGraderPage.selectCommentsTab() val testText = "another" - Log.d(STEP_TAG,"Type $testText word and check if there is only one matching suggestion visible.") + Log.d(STEP_TAG,"Type '$testText' word and check if there is only one matching suggestion visible.") speedGraderCommentsPage.typeComment(testText) commentLibraryPage.assertPageObjects() commentLibraryPage.assertSuggestionsCount(1) @@ -89,7 +89,7 @@ class CommentLibraryE2ETest : TeacherTest() { commentLibraryPage.assertSuggestionsCount(2) val testText2 = "test" - Log.d(STEP_TAG,"Type $testText2 word and check if there are two matching suggestion visible.") + Log.d(STEP_TAG,"Type '$testText2' word and check if there are two matching suggestion visible.") commentLibraryPage.closeCommentLibrary() speedGraderCommentsPage.typeComment(testText2) commentLibraryPage.assertPageObjects() @@ -117,30 +117,15 @@ class CommentLibraryE2ETest : TeacherTest() { commentLibraryPage.assertEmptyViewVisible() } - private fun prepareData( - courseId: Long, + private fun prepareSettingsAndMakeAssignmentWithSubmission( + course: CourseApiModel, studentToken: String, teacherToken: String, teacherId: Long ): AssignmentApiModel { - val testAssignment = AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = GradingType.POINTS, - teacherToken = teacherToken, - pointsPossible = 25.0, - dueAt = 1.days.fromNow.iso8601 - ) - ) - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_TEXT_ENTRY, - courseId = courseId, - assignmentId = testAssignment.id, - fileIds = emptyList().toMutableList(), - studentToken = studentToken - ) + val testAssignment = AssignmentsApi.createAssignment(course.id, teacherToken, pointsPossible = 25.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) + SubmissionsApi.submitCourseAssignment(course.id, studentToken, testAssignment.id, submissionType = SubmissionType.ONLINE_TEXT_ENTRY) val request = UserSettingsApiModel( manualMarkAsRead = false, diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CourseSettingsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CourseSettingsE2ETest.kt index ba5c4d92bc..3fdd3691c6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CourseSettingsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CourseSettingsE2ETest.kt @@ -48,10 +48,10 @@ class CourseSettingsE2ETest : TeacherTest() { val firstCourse = data.coursesList[0] val secondCourse = data.coursesList[1] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) - Log.d(STEP_TAG, "Open ${firstCourse.name} course and click on Course Settings button.") + Log.d(STEP_TAG, "Open '${firstCourse.name}' course and click on Course Settings button.") dashboardPage.waitForRender() dashboardPage.openCourse(firstCourse) courseBrowserPage.clickSettingsButton() @@ -63,7 +63,7 @@ class CourseSettingsE2ETest : TeacherTest() { courseSettingsPage.assertHomePageChanged(newCourseHomePage) val newCourseName = "New Course Name" - Log.d(STEP_TAG, "Click on 'Course Name' menu and edit course's name to be $newCourseName. Assert that the course's name has been changed.") + Log.d(STEP_TAG, "Click on 'Course Name' menu and edit course's name to be '$newCourseName'. Assert that the course's name has been changed.") courseSettingsPage.clickCourseName() courseSettingsPage.editCourseName(newCourseName) courseSettingsPage.assertCourseNameChanged(newCourseName) @@ -78,7 +78,7 @@ class CourseSettingsE2ETest : TeacherTest() { dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(newCourseName) - Log.d(STEP_TAG, "Open ${secondCourse.name} course and click on Course Settings button.") + Log.d(STEP_TAG, "Open '${secondCourse.name}' course and click on Course Settings button.") dashboardPage.waitForRender() dashboardPage.openCourse(secondCourse) courseBrowserPage.clickSettingsButton() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DashboardE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DashboardE2ETest.kt index f0899b4832..c135b476ad 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DashboardE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DashboardE2ETest.kt @@ -86,7 +86,7 @@ class DashboardE2ETest : TeacherTest() { dashboardPage.assertDisplaysCourse(course2) dashboardPage.assertCourseNotDisplayed(course1) - Log.d(STEP_TAG,"Opens ${course2.name} course and assert if Course Details Page has been opened. Navigate back to Dashboard Page.") + Log.d(STEP_TAG,"Opens '${course2.name}' course and assert if Course Details Page has been opened. Navigate back to Dashboard Page.") dashboardPage.assertOpensCourse(course2) Espresso.pressBack() @@ -146,11 +146,12 @@ class DashboardE2ETest : TeacherTest() { @Test @TestMetaData(Priority.NICE_TO_HAVE, FeatureCategory.DASHBOARD, TestCategory.E2E) fun testHelpMenuE2E() { + Log.d(PREPARATION_TAG,"Seeding data.") val data = seedData(teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG,"Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG,"Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() 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 b81217bb91..3792c0fc5a 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 @@ -31,6 +31,7 @@ import org.junit.Test @HiltAndroidTest class DiscussionsE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -47,11 +48,11 @@ class DiscussionsE2ETest : TeacherTest() { val discussion = data.discussionsList[0] val discussion2 = data.discussionsList[1] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Open ${course.name} course.") + Log.d(STEP_TAG,"Open '${course.name}' course.") dashboardPage.openCourse(course.name) courseBrowserPage.waitForRender() @@ -90,7 +91,7 @@ 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.") + Log.d(STEP_TAG, "Assert that both of the discussions, '${discussion.title}' and '${discussion2.title}' discussions are displayed.") discussionsListPage.assertHasDiscussion(newTitle) discussionsListPage.assertHasDiscussion(discussion2) 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 942c32b4da..168d5053f9 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 @@ -26,18 +26,12 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvasapi2.managers.DiscussionManager import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.DiscussionEntry import com.instructure.canvasapi2.utils.weave.awaitApiResponse import com.instructure.canvasapi2.utils.weave.catch 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.AssignmentApiModel -import com.instructure.dataseeding.model.AttachmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel -import com.instructure.dataseeding.model.DiscussionApiModel import com.instructure.dataseeding.model.FileUploadType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.Randomizer @@ -53,6 +47,7 @@ import java.io.FileWriter @HiltAndroidTest class FilesE2ETest: TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -69,7 +64,7 @@ class FilesE2ETest: TeacherTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG, "Seed a text assignment/file/submission.") - val assignment = createAssignment(course, teacher) + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), allowedExtensions = listOf("txt")) Log.d(PREPARATION_TAG, "Seed a text file.") val submissionUploadInfo = uploadTextFile( @@ -79,8 +74,8 @@ class FilesE2ETest: TeacherTest() { fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION ) - Log.d(PREPARATION_TAG, "Submit the ${assignment.name} assignment.") - submitCourseAssignment(course, assignment, submissionUploadInfo, student) + Log.d(PREPARATION_TAG, "Submit the '${assignment.name}' assignment.") + SubmissionsApi.submitCourseAssignment(course.id, student.token, assignment.id, submissionType = SubmissionType.ONLINE_UPLOAD, fileIds = mutableListOf(submissionUploadInfo.id)) Log.d(PREPARATION_TAG,"Seed a comment attachment upload.") val commentUploadInfo = uploadTextFile( @@ -90,10 +85,11 @@ class FilesE2ETest: TeacherTest() { fileUploadType = FileUploadType.COMMENT_ATTACHMENT ) - commentOnSubmission(student, course, assignment, commentUploadInfo) + Log.d(PREPARATION_TAG, "Comment a text file as a teacher to the '${student.name}' student's submission of the '${assignment.name}' assignment.") + SubmissionsApi.commentOnSubmission(course.id, student.token, assignment.id, fileIds = mutableListOf(commentUploadInfo.id)) Log.d(PREPARATION_TAG,"Seed a discussion topic. Will add a reply with attachment below.") - val discussionTopic = createDiscussion(course, student) + val discussionTopic= DiscussionTopicsApi.createDiscussion(course.id, student.token) Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") tokenLogin(teacher) @@ -113,7 +109,7 @@ class FilesE2ETest: TeacherTest() { Log.d(PREPARATION_TAG,"Use real API (rather than seeding) to create a reply to our discussion that contains an attachment.") tryWeave { - awaitApiResponse { + awaitApiResponse { DiscussionManager.postToDiscussionTopic( canvasContext = CanvasContext.emptyCourseContext(id = course.id), topicId = discussionTopic.id, @@ -132,26 +128,26 @@ class FilesE2ETest: TeacherTest() { 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,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") + 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) Log.d(STEP_TAG,"Navigate back to the Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Assignments Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Assignments Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openAssignmentsTab() - Log.d(STEP_TAG,"Click on ${assignment.name} assignment and navigate to Submissions Page.") + Log.d(STEP_TAG,"Click on '${assignment.name}' assignment and navigate to Submissions Page.") assignmentListPage.clickAssignment(assignment) assignmentDetailsPage.openSubmissionsPage() - Log.d(STEP_TAG,"Click on ${student.name} student's submission and navigate to Files Tab.") + Log.d(STEP_TAG,"Click on '${student.name}' student's submission and navigate to Files Tab.") assignmentSubmissionListPage.clickSubmission(student) speedGraderPage.selectFilesTab(1) - Log.d(STEP_TAG,"Assert that ${submissionUploadInfo.fileName} file. Navigate to Comments Tab and ${commentUploadInfo.fileName} comment attachment is displayed.") + Log.d(STEP_TAG,"Assert that '${submissionUploadInfo.fileName}' file. Navigate to Comments Tab and '${commentUploadInfo.fileName}' comment attachment is displayed.") assignmentSubmissionListPage.assertFileDisplayed(submissionUploadInfo.fileName) speedGraderPage.selectCommentsTab() assignmentSubmissionListPage.assertCommentAttachmentDisplayedCommon(commentUploadInfo.fileName, student.shortName) @@ -178,25 +174,25 @@ class FilesE2ETest: TeacherTest() { 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.") + 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) - Log.d(STEP_TAG,"Select ${discussionAttachmentFile.name} file.") + Log.d(STEP_TAG,"Select '${discussionAttachmentFile.name}' file.") fileListPage.selectItem(discussionAttachmentFile.name) val newFileName = "newFileName.txt" - Log.d(STEP_TAG,"Rename ${discussionAttachmentFile.name} file to: $newFileName.") + Log.d(STEP_TAG,"Rename '${discussionAttachmentFile.name}' file to: '$newFileName'.") fileListPage.renameFile(newFileName) Log.d(STEP_TAG,"Navigate back to File List Page.") Espresso.pressBack() fileListPage.assertPageObjects() - Log.d(STEP_TAG,"Assert that the file is displayed with it's new file name: $newFileName.") + Log.d(STEP_TAG,"Assert that the file is displayed with it's new file name: '$newFileName'.") fileListPage.assertItemDisplayed(newFileName) - Log.d(STEP_TAG,"Delete $newFileName file.") + Log.d(STEP_TAG,"Delete '$newFileName' file.") fileListPage.deleteFile(newFileName) fileListPage.assertPageObjects() @@ -209,7 +205,7 @@ class FilesE2ETest: TeacherTest() { fileListPage.createFolder(newFolderName) 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.") + Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '$newFolderName', the file's name to the search input field.") fileListPage.searchable.clickOnSearchButton() fileListPage.searchable.typeToSearchBar(newFolderName) @@ -222,58 +218,4 @@ class FilesE2ETest: TeacherTest() { fileListPage.assertItemNotDisplayed(newFolderName) } - private fun createDiscussion( - course: CourseApiModel, - student: CanvasUserApiModel - ): DiscussionApiModel { - return DiscussionTopicsApi.createDiscussion( - courseId = course.id, - token = student.token - ) - } - - private fun commentOnSubmission( - student: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - commentUploadInfo: AttachmentApiModel - ) { - SubmissionsApi.commentOnSubmission( - studentToken = student.token, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(commentUploadInfo.id) - ) - } - - private fun submitCourseAssignment( - course: CourseApiModel, - assignment: AssignmentApiModel, - submissionUploadInfo: AttachmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_UPLOAD, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(submissionUploadInfo.id), - studentToken = student.token - ) - } - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = false, - submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), - allowedExtensions = listOf("txt"), - teacherToken = teacher.token - ) - ) - } - } \ No newline at end of file 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 4c74dc3ff7..2ae6c04d5f 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 @@ -23,15 +23,16 @@ import org.junit.Test @HiltAndroidTest class InboxE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit - @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] @@ -41,10 +42,10 @@ class InboxE2ETest : TeacherTest() { 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}.") + 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}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course) @@ -80,11 +81,11 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG,"Add a new conversation message manually via UI. Click on 'New Message' ('+') button.") inboxPage.clickAddMessageFAB() - Log.d(STEP_TAG,"Select ${course.name} from course spinner.Click on the '+' icon next to the recipients input field. Select the two students: ${student1.name} and ${student2.name}. Click on 'Done'.") + Log.d(STEP_TAG,"Select '${course.name}' from course spinner. Click on the '+' icon next to the recipients input field. Select the two students: '${student1.name}' and '${student2.name}'. Click on 'Done'.") addNewMessage(course,data.studentsList) val subject = "Hello there" - Log.d(STEP_TAG,"Fill in the 'Subject' field with the value: $subject. Add some message text and click on 'Send' (aka. 'Arrow') button.") + 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() @@ -94,7 +95,7 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert that the previously sent conversation is displayed.") inboxPage.assertHasConversation() - Log.d(STEP_TAG,"Click on $subject conversation.") + Log.d(STEP_TAG,"Click on '$subject' conversation.") inboxPage.clickConversation(subject) val replyMessageTwo = "Test Reply 2" @@ -171,10 +172,10 @@ class InboxE2ETest : TeacherTest() { 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}.") + 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}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course) @@ -184,30 +185,17 @@ class InboxE2ETest : TeacherTest() { inboxPage.assertInboxEmpty() Log.d(PREPARATION_TAG, "Seed an Inbox conversation via API.") - val seedConversation = ConversationsApi.createConversation( - token = student1.token, - recipients = listOf(teacher.id.toString()) - ) + 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( - token = student1.token, - recipients = listOf(teacher.id.toString()), - subject = "Second conversation", - body = "Second body" - ) + 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" - ) + val seedConversation3 = ConversationsApi.createConversation(token = student2.token, recipients = listOf(teacher.id.toString()), subject = "Third conversation", body = "Third body") 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() @@ -234,11 +222,11 @@ class InboxE2ETest : TeacherTest() { inboxPage.clickUnArchive() inboxPage.assertConversationNotDisplayed(seedConversation2[0].subject) - Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that ${seedConversation2[0].subject} conversation is displayed.") + Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that '${seedConversation2[0].subject}' conversation is displayed.") inboxPage.filterMessageScope("Inbox") inboxPage.assertConversationDisplayed(seedConversation2[0].subject) - Log.d(STEP_TAG, "Select both of the conversations (${seedConversation[0].subject} and ${seedConversation2[0].subject} and star them." + + 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.assertSelectedConversationNumber("2") @@ -303,6 +291,7 @@ class InboxE2ETest : TeacherTest() { @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] @@ -312,10 +301,10 @@ class InboxE2ETest : TeacherTest() { 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}.") + 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}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course) @@ -325,10 +314,7 @@ class InboxE2ETest : TeacherTest() { inboxPage.assertInboxEmpty() Log.d(PREPARATION_TAG, "Seed an Inbox conversation via API.") - val seedConversation = ConversationsApi.createConversation( - token = student1.token, - recipients = listOf(teacher.id.toString()) - ) + 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() @@ -454,10 +440,10 @@ class InboxE2ETest : TeacherTest() { 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}.") + 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}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/LoginE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/LoginE2ETest.kt index 9b6ff40f1c..1c7ffa3be7 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/LoginE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/LoginE2ETest.kt @@ -33,6 +33,7 @@ import org.junit.Test @HiltAndroidTest class LoginE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -48,19 +49,19 @@ class LoginE2ETest : TeacherTest() { val teacher2 = data.teachersList[1] val course = data.coursesList[0] - Log.d(STEP_TAG, "Login with user: ${teacher1.name}, login id: ${teacher1.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher1.name}', login id: '${teacher1.loginId}'.") loginWithUser(teacher1) Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertSuccessfulLogin(teacher1) - Log.d(STEP_TAG,"Validate ${teacher1.name} user's role as a Teacher.") + Log.d(STEP_TAG,"Validate '${teacher1.name}' user's role as a Teacher.") validateUserRole(teacher1, course, "Teacher") - Log.d(STEP_TAG,"Log out with ${teacher1.name} student.") + Log.d(STEP_TAG,"Log out with '${teacher1.name}' student.") leftSideNavigationDrawerPage.logout() - Log.d(STEP_TAG, "Login with user: ${teacher2.name}, login id: ${teacher2.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher2.name}', login id: '${teacher2.loginId}'.") loginWithUser(teacher2, true) Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") @@ -72,7 +73,7 @@ class LoginE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert that the previously logins has been displayed.") loginLandingPage.assertDisplaysPreviousLogins() - Log.d(STEP_TAG, "Login with user: ${teacher1.name}, login id: ${teacher1.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher1.name}', login id: '${teacher1.loginId}'.") loginWithUser(teacher1, true) Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") @@ -84,7 +85,7 @@ class LoginE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert that the previously logins has been displayed.") loginLandingPage.assertDisplaysPreviousLogins() - Log.d(STEP_TAG,"Login with the previous user, ${teacher2.name}, with one click, by clicking on the user's name on the bottom.") + Log.d(STEP_TAG,"Login with the previous user, '${teacher2.name}', with one click, by clicking on the user's name on the bottom.") loginLandingPage.loginWithPreviousUser(teacher2) Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") @@ -106,7 +107,7 @@ class LoginE2ETest : TeacherTest() { val student = data.studentsList[0] val parent = parentData.parentsList[0] - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") loginWithUser(student) Log.d(STEP_TAG,"Assert that the user has been landed on 'Not a teacher?' Page.") @@ -118,7 +119,7 @@ class LoginE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert the Teacher app's Login Landing Page's screen is displayed.") loginLandingPage.assertPageObjects() - Log.d(STEP_TAG, "Login with user: ${parent.name}, login id: ${parent.loginId}.") + Log.d(STEP_TAG, "Login with user: '${parent.name}', login id: '${parent.loginId}'.") loginWithUser(parent, true) Log.d(STEP_TAG,"Assert that the user has been landed on 'Not a teacher?' Page.") @@ -141,16 +142,16 @@ class LoginE2ETest : TeacherTest() { val teacher1 = data.teachersList[0] val teacher2 = data.teachersList[1] - Log.d(STEP_TAG, "Login with user: ${teacher1.name}, login id: ${teacher1.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher1.name}', login id: '${teacher1.loginId}'.") loginWithUser(teacher1) Log.d(STEP_TAG, "Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertSuccessfulLogin(teacher1) - Log.d(STEP_TAG, "Log out with ${teacher1.name} student.") + Log.d(STEP_TAG, "Log out with '${teacher1.name}' student.") leftSideNavigationDrawerPage.logout() - Log.d(STEP_TAG, "Login with user: ${teacher2.name}, login id: ${teacher2.loginId}, via the last saved school's button.") + Log.d(STEP_TAG, "Login with user: '${teacher2.name}', login id: '${teacher2.loginId}', via the last saved school's button.") loginWithLastSavedSchool(teacher2) Log.d(STEP_TAG, "Assert that the Dashboard Page is the landing page and it is loaded successfully.") @@ -171,13 +172,13 @@ class LoginE2ETest : TeacherTest() { Log.d(STEP_TAG, "Click 'Find My School' button.") loginLandingPage.clickFindMySchoolButton() - Log.d(STEP_TAG,"Enter domain: $DOMAIN.instructure.com.") + Log.d(STEP_TAG,"Enter domain: '$DOMAIN.instructure.com'.") loginFindSchoolPage.enterDomain(DOMAIN) Log.d(STEP_TAG,"Click on 'Next' button on the Toolbar.") loginFindSchoolPage.clickToolbarNextMenuItem() - Log.d(STEP_TAG, "Try to login with invalid, non-existing credentials ($INVALID_USERNAME, $INVALID_PASSWORD)." + + Log.d(STEP_TAG, "Try to login with invalid, non-existing credentials: '$INVALID_USERNAME', '$INVALID_PASSWORD'." + "Assert that the invalid credentials error message is displayed.") loginSignInPage.loginAs(INVALID_USERNAME, INVALID_PASSWORD) loginSignInPage.assertLoginErrorMessage(INVALID_CREDENTIALS_ERROR_MESSAGE) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt index 900c775687..b0aaaa1a5a 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt @@ -2,7 +2,6 @@ package com.instructure.teacher.ui.e2e import android.util.Log import androidx.test.espresso.Espresso -import androidx.test.espresso.web.webdriver.Locator import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority @@ -13,26 +12,22 @@ 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.AssignmentApiModel -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.PageApiModel -import com.instructure.dataseeding.model.QuizApiModel 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.teacher.ui.pages.WebViewTextCheck -import com.instructure.teacher.ui.utils.TeacherTest +import com.instructure.teacher.R +import com.instructure.teacher.ui.utils.TeacherComposeTest +import com.instructure.teacher.ui.utils.openOverflowMenu import com.instructure.teacher.ui.utils.seedData import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @HiltAndroidTest -class ModulesE2ETest : TeacherTest() { +class ModulesE2ETest : TeacherComposeTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -47,205 +42,434 @@ class ModulesE2ETest : TeacherTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}. Assert that ${course.name} course is displayed on the Dashboard.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'. Assert that '${course.name}' course is displayed on the Dashboard.") tokenLogin(teacher) dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course) - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Modules Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Modules Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openModulesTab() Log.d(STEP_TAG,"Assert that empty view is displayed because there is no Module within the course.") - modulesPage.assertEmptyView() - - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val assignment = createAssignment(course, teacher) - - Log.d(PREPARATION_TAG,"Seeding quiz for ${course.name} course.") - val quiz = createQuiz(course, teacher) - - Log.d(PREPARATION_TAG,"Create an unpublished page for course: ${course.name}.") - val testPage = createCoursePage(course, teacher, published = false, frontPage = false, body = "

Test Page Text

") - - Log.d(PREPARATION_TAG,"Create a discussion topic for ${course.name} course.") - val discussionTopic = createDiscussion(course, teacher) - - Log.d(PREPARATION_TAG,"Seeding a module for ${course.name} course. It starts as unpublished.") - val module = createModule(course, teacher) - - Log.d(PREPARATION_TAG,"Associate ${assignment.name} assignment (and the quiz within it) with module: ${module.id}.") - createModuleItem(course, module, teacher, assignment.name, ModuleItemTypes.ASSIGNMENT.stringVal, assignment.id.toString()) - createModuleItem(course, module, teacher, quiz.title, ModuleItemTypes.QUIZ.stringVal, quiz.id.toString()) - - Log.d(PREPARATION_TAG,"Associate ${testPage.title} page with module: ${module.id}.") - createModuleItem(course, module, teacher, testPage.title, ModuleItemTypes.PAGE.stringVal, null, pageUrl = testPage.url) - - Log.d(PREPARATION_TAG,"Associate ${discussionTopic.title} discussion with module: ${module.id}.") - createModuleItem(course, module, teacher, discussionTopic.title, ModuleItemTypes.DISCUSSION.stringVal, discussionTopic.id.toString()) - - Log.d(STEP_TAG,"Refresh the page. Assert that ${module.name} module is displayed and it is unpublished by default.") - modulesPage.refresh() - modulesPage.assertModuleIsDisplayed(module.name) - modulesPage.assertModuleNotPublished() - - Log.d(STEP_TAG,"Assert that ${testPage.title} page is present as a module item, but it's not published.") - modulesPage.assertModuleItemIsDisplayed(testPage.title) - modulesPage.assertModuleItemNotPublished(module.name, testPage.title) - - Log.d(PREPARATION_TAG,"Publish ${module.name} module via API.") - ModulesApi.updateModule( - courseId = course.id, - id = module.id, - published = true, - teacherToken = teacher.token - ) - - Log.d(STEP_TAG,"Refresh the page. Assert that ${module.name} module is displayed and it is published.") - modulesPage.refresh() - modulesPage.assertModuleIsDisplayed(module.name) - modulesPage.assertModuleIsPublished() - - Log.d(STEP_TAG,"Assert that ${assignment.name} assignment and ${quiz.title} quiz are present as module items, and they are published since their module is published.") - modulesPage.assertModuleItemIsDisplayed(assignment.name) - modulesPage.assertModuleItemIsPublished(assignment.name) - modulesPage.assertModuleItemIsDisplayed(quiz.title) - modulesPage.assertModuleItemIsPublished(quiz.title) - - Log.d(STEP_TAG,"Assert that ${testPage.title} page is present as a module item, but it's not published.") - modulesPage.assertModuleItemIsDisplayed(testPage.title) - modulesPage.assertModuleItemIsPublished(testPage.title) - - Log.d(STEP_TAG, "Collapse the ${module.name} and assert that the module items has not displayed.") - modulesPage.clickOnCollapseExpandIcon() - modulesPage.assertItemCountInModule(module.name, 0) - - Log.d(STEP_TAG, "Expand the ${module.name} and assert that the module items are displayed.") - modulesPage.clickOnCollapseExpandIcon() - modulesPage.assertItemCountInModule(module.name, 4) - - Log.d(PREPARATION_TAG,"Unpublish ${module.name} module via API.") - ModulesApi.updateModule( - courseId = course.id, - id = module.id, - published = false, - teacherToken = teacher.token - ) - - Log.d(STEP_TAG, "Refresh the Modules Page.") - modulesPage.refresh() - - Log.d(STEP_TAG,"Assert that ${assignment.name} assignment and ${quiz.title} quiz and ${testPage.title} page are present as module items, and they are NOT published since their module is unpublished.") - modulesPage.assertModuleItemIsDisplayed(assignment.name) - modulesPage.assertModuleItemNotPublished(module.name, assignment.name) - modulesPage.assertModuleItemIsDisplayed(quiz.title) - modulesPage.assertModuleItemNotPublished(module.name, quiz.title) - modulesPage.assertModuleItemIsDisplayed(testPage.title) - modulesPage.assertModuleItemNotPublished(module.name, testPage.title) - - Log.d(STEP_TAG, "Open the ${assignment.name} assignment module item and assert that the Assignment Details Page is displayed. Navigate back to Modules Page.") - modulesPage.clickOnModuleItem(assignment.name) + moduleListPage.assertEmptyView() + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 1.days.fromNow.iso8601) + + Log.d(PREPARATION_TAG,"Seeding quiz for '${course.name}' course.") + val quiz = QuizzesApi.createQuiz(course.id, teacher.token, withDescription = true, dueAt = 3.days.fromNow.iso8601) + + Log.d(PREPARATION_TAG,"Create an unpublished page for course: '${course.name}'.") + val testPage = PagesApi.createCoursePage(course.id, teacher.token, published = false, body = "

Test Page Text

") + + Log.d(PREPARATION_TAG,"Create a discussion topic for '${course.name}' course.") + val discussionTopic = DiscussionTopicsApi.createDiscussion(courseId = course.id, token = teacher.token) + + Log.d(PREPARATION_TAG,"Seeding a module for '${course.name}' course. It starts as unpublished.") + val module = ModulesApi.createModule(course.id, teacher.token) + + Log.d(PREPARATION_TAG,"Associate '${assignment.name}' assignment with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = assignment.name, moduleItemType = ModuleItemTypes.ASSIGNMENT.stringVal, contentId = assignment.id.toString()) + + Log.d(PREPARATION_TAG,"Associate '${quiz.title}' quiz with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = quiz.title, moduleItemType = ModuleItemTypes.QUIZ.stringVal, contentId = quiz.id.toString()) + + Log.d(PREPARATION_TAG,"Associate '${testPage.title}' page with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = testPage.title, moduleItemType = ModuleItemTypes.PAGE.stringVal, contentId = null, pageUrl = testPage.url) + + Log.d(PREPARATION_TAG,"Associate '${discussionTopic.title}' discussion with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = discussionTopic.title, moduleItemType = ModuleItemTypes.DISCUSSION.stringVal, contentId = discussionTopic.id.toString()) + + Log.d(STEP_TAG,"Refresh the page. Assert that '${module.name}' module is displayed and it is unpublished by default.") + moduleListPage.refresh() + moduleListPage.assertModuleIsDisplayed(module.name) + moduleListPage.assertModuleNotPublished(module.name) + + Log.d(STEP_TAG,"Assert that '${testPage.title}' page is present as a module item, but it's not published.") + moduleListPage.assertModuleItemIsDisplayed(testPage.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) + + Log.d(PREPARATION_TAG,"Publish '${module.name}' module via API.") + ModulesApi.updateModule(courseId = course.id, moduleId = module.id, published = true, teacherToken = teacher.token) + + Log.d(STEP_TAG,"Refresh the page. Assert that '${module.name}' module is displayed and it is published.") + moduleListPage.refresh() + moduleListPage.assertModuleIsDisplayed(module.name) + moduleListPage.assertModuleIsPublished() + + Log.d(STEP_TAG,"Assert that '${assignment.name}' assignment and '${quiz.title}' quiz are present as module items, and they are published since their module is published.") + moduleListPage.assertModuleItemIsDisplayed(assignment.name) + moduleListPage.assertModuleItemIsPublished(assignment.name) + moduleListPage.assertModuleItemIsDisplayed(quiz.title) + moduleListPage.assertModuleItemIsPublished(quiz.title) + + Log.d(STEP_TAG,"Assert that '${testPage.title}' page is present as a module item, but it's not published.") + moduleListPage.assertModuleItemIsDisplayed(testPage.title) + moduleListPage.assertModuleItemIsPublished(testPage.title) + + Log.d(STEP_TAG, "Collapse the '${module.name}' and assert that the module items has not displayed.") + moduleListPage.clickOnCollapseExpandIcon() + moduleListPage.assertItemCountInModule(module.name, 0) + + Log.d(STEP_TAG, "Expand the '${module.name}' and assert that the module items are displayed.") + moduleListPage.clickOnCollapseExpandIcon() + moduleListPage.assertItemCountInModule(module.name, 4) + + Log.d(STEP_TAG, "Open the '${assignment.name}' assignment module item and assert that the Assignment Details Page is displayed. Assert that the module name is displayed at the bottom.") + moduleListPage.clickOnModuleItem(assignment.name) assignmentDetailsPage.assertPageObjects() + assignmentDetailsPage.assertAssignmentDetails(assignment) + assignmentDetailsPage.moduleItemInteractions.assertModuleNameDisplayed(module.name) + + Log.d(STEP_TAG, "Assert that the previous arrow button is not displayed because the user is on the first assignment's details page, but the next arrow button is displayed.") + assignmentDetailsPage.moduleItemInteractions.assertPreviousArrowNotDisplayed() + assignmentDetailsPage.moduleItemInteractions.assertNextArrowDisplayed() + + Log.d(STEP_TAG, "Click on the next arrow button and assert that the '${quiz.title}' quiz module item's details page is displayed. Assert that the module name is displayed at the bottom.") + assignmentDetailsPage.moduleItemInteractions.clickOnNextArrow() + quizDetailsPage.assertQuizDetails(quiz) + quizDetailsPage.moduleItemInteractions.assertModuleNameDisplayed(module.name) + + Log.d(STEP_TAG, "Assert that both the previous and next arrow buttons are displayed.") + quizDetailsPage.moduleItemInteractions.assertPreviousArrowDisplayed() + quizDetailsPage.moduleItemInteractions.assertNextArrowDisplayed() + + Log.d(STEP_TAG, "Click on the next arrow button and assert that the '${testPage.title}' page module item's details page is displayed. Assert that the module name is displayed at the bottom.") + quizDetailsPage.moduleItemInteractions.clickOnNextArrow() + editPageDetailsPage.assertPageDetails(testPage) + editPageDetailsPage.moduleItemInteractions.assertModuleNameDisplayed(module.name) + + Log.d(STEP_TAG, "Assert that both the previous and next arrow buttons are displayed.") + editPageDetailsPage.moduleItemInteractions.assertPreviousArrowDisplayed() + editPageDetailsPage.moduleItemInteractions.assertNextArrowDisplayed() + + Log.d(STEP_TAG, "Click on the next arrow button and assert that the '${discussionTopic.title}' discussion module item's details page is displayed. Assert that the module name is displayed at the bottom.") + editPageDetailsPage.moduleItemInteractions.clickOnNextArrow() + discussionsDetailsPage.assertDiscussionTitle(discussionTopic.title) + discussionsDetailsPage.assertDiscussionPublished() + discussionsDetailsPage.moduleItemInteractions.assertModuleNameDisplayed(module.name) + + Log.d(STEP_TAG, "Assert that the next arrow button is not displayed because the user is on the last assignment's details page, but the previous arrow button is displayed.") + discussionsDetailsPage.moduleItemInteractions.assertPreviousArrowDisplayed() + discussionsDetailsPage.moduleItemInteractions.assertNextArrowNotDisplayed() + + Log.d(STEP_TAG, "Click on the previous arrow button and assert that the '${testPage.title}' page module item's details page is displayed. Assert that the module name is displayed at the bottom.") + quizDetailsPage.moduleItemInteractions.clickOnPreviousArrow() + editPageDetailsPage.assertPageDetails(testPage) + editPageDetailsPage.moduleItemInteractions.assertModuleNameDisplayed(module.name) + + Log.d(STEP_TAG, "Navigate back to Module List Page.") Espresso.pressBack() - Log.d(STEP_TAG, "Open the ${quiz.title} quiz module item and assert that the Quiz Details Page is displayed. Navigate back to Modules Page.") - modulesPage.clickOnModuleItem(quiz.title) - quizDetailsPage.assertPageObjects() - Espresso.pressBack() + Log.d(PREPARATION_TAG,"Unpublish '${module.name}' module via API.") + ModulesApi.updateModule(courseId = course.id, moduleId = module.id, published = false, teacherToken = teacher.token) - Log.d(STEP_TAG, "Open the ${testPage.title} page module item and assert that the Page Details Page is displayed. Navigate back to Modules Page.") - modulesPage.clickOnModuleItem(testPage.title) - editPageDetailsPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Test Page Text")) - Espresso.pressBack() + Log.d(STEP_TAG, "Refresh the Module List Page.") + moduleListPage.refresh() - Log.d(STEP_TAG, "Open the ${discussionTopic.title} discussion module item and assert that the Discussion Details Page is displayed.") - modulesPage.clickOnModuleItem(discussionTopic.title) - discussionsDetailsPage.assertPageObjects() - } + Log.d(STEP_TAG,"Assert that '${assignment.name}' assignment and '${quiz.title}' quiz and '${testPage.title}' page are present as module items, and they are NOT published since their module is unpublished.") + moduleListPage.assertModuleItemIsDisplayed(assignment.name) + moduleListPage.assertModuleItemNotPublished(assignment.name) + moduleListPage.assertModuleItemIsDisplayed(quiz.title) + moduleListPage.assertModuleItemNotPublished(quiz.title) + moduleListPage.assertModuleItemIsDisplayed(testPage.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) - private fun createModuleItem( - course: CourseApiModel, - module: ModuleApiModel, - teacher: CanvasUserApiModel, - title: String, - moduleItemType: String, - contentId: String?, - pageUrl: String? = null - ) { - ModulesApi.createModuleItem( - courseId = course.id, - moduleId = module.id, - teacherToken = teacher.token, - title = title, - type = moduleItemType, - contentId = contentId, - pageUrl = pageUrl - ) - } + Log.d(STEP_TAG, "Open the '${assignment.name}' assignment module item and assert that the Assignment Details Page is displayed") + moduleListPage.clickOnModuleItem(assignment.name) - private fun createModule( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): ModuleApiModel { - return ModulesApi.createModule( - courseId = course.id, - teacherToken = teacher.token, - unlockAt = null - ) - } + Log.d(STEP_TAG, "Assert that the published status of the '${assignment.name}' assignment became 'Unpublished' on the Assignment Details Page.") + assignmentDetailsPage.assertPublishedStatus(false) - private fun createQuiz( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): QuizApiModel { - return QuizzesApi.createQuiz( - QuizzesApi.CreateQuizRequest( - courseId = course.id, - withDescription = true, - dueAt = 3.days.fromNow.iso8601, - token = teacher.token, - published = true - ) - ) - } + Log.d(STEP_TAG, "Open Edit Page of '${assignment.name}' assignment and publish it. Save the change.") + assignmentDetailsPage.openEditPage() + editAssignmentDetailsPage.clickPublishSwitch() + editAssignmentDetailsPage.saveAssignment() - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = true, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - teacherToken = teacher.token, - dueAt = 1.days.fromNow.iso8601 - ) - ) - } + Log.d(STEP_TAG, "Assert that the published status of the '${assignment.name}' assignment became 'Published' on the Assignment Details Page (as well).") + assignmentDetailsPage.assertPublishedStatus(true) - private fun createCoursePage( - course: CourseApiModel, - teacher: CanvasUserApiModel, - published: Boolean = true, - frontPage: Boolean = false, - body: String = EMPTY_STRING - ): PageApiModel { - return PagesApi.createCoursePage( - courseId = course.id, - published = published, - frontPage = frontPage, - token = teacher.token, - body = body - ) + Log.d(STEP_TAG, "Navigate back to Module List Page and assert that the '${assignment.name}' assignment module item's status became 'Published'.") + Espresso.pressBack() + moduleListPage.assertModuleItemIsPublished(assignment.name) } - private fun createDiscussion( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = DiscussionTopicsApi.createDiscussion( - courseId = course.id, - token = teacher.token - ) + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.MODULES, TestCategory.E2E) + fun testBulkUpdateModulesE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 1.days.fromNow.iso8601) + + Log.d(PREPARATION_TAG, "Seeding another 'Text Entry' assignment for '${course.name}' course.") + val assignment2 = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 1.days.fromNow.iso8601) + + Log.d(PREPARATION_TAG, "Seeding quiz for '${course.name}' course.") + val quiz = QuizzesApi.createQuiz(course.id, teacher.token, withDescription = true, dueAt = 3.days.fromNow.iso8601) + + Log.d(PREPARATION_TAG, "Create an unpublished page for course: '${course.name}'.") + val testPage = PagesApi.createCoursePage(course.id, teacher.token, published = false, body = "

Test Page Text

") + + Log.d(PREPARATION_TAG, "Create another unpublished page for course: '${course.name}'.") + val testPage2 = PagesApi.createCoursePage(course.id, teacher.token, published = true, frontPage = false, body = "

This is another test page

") + + Log.d(PREPARATION_TAG, "Create a discussion topic for '${course.name}' course.") + val discussionTopic = DiscussionTopicsApi.createDiscussion(courseId = course.id, token = teacher.token) + + Log.d(PREPARATION_TAG, "Seeding a module for '${course.name}' course. It starts as unpublished.") + val module = ModulesApi.createModule(course.id, teacher.token) + + Log.d(PREPARATION_TAG, "Seeding another module for '${course.name}' course. It starts as unpublished.") + val module2 = ModulesApi.createModule(course.id, teacher.token) + + Log.d(PREPARATION_TAG,"Associate '${assignment.name}' assignment with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = assignment.name, moduleItemType = ModuleItemTypes.ASSIGNMENT.stringVal, contentId = assignment.id.toString()) + + Log.d(PREPARATION_TAG,"Associate '${quiz.title}' quiz with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = quiz.title, moduleItemType = ModuleItemTypes.QUIZ.stringVal, contentId = quiz.id.toString()) + + Log.d(PREPARATION_TAG,"Associate '${testPage.title}' page with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = testPage.title, moduleItemType = ModuleItemTypes.PAGE.stringVal, contentId = null, pageUrl = testPage.url) + + Log.d(PREPARATION_TAG,"Associate '${discussionTopic.title}' discussion with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = discussionTopic.title, moduleItemType = ModuleItemTypes.DISCUSSION.stringVal, contentId = discussionTopic.id.toString()) + + Log.d(PREPARATION_TAG, "Associate '${assignment2.name}' assignment with module: '${module2.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module2.id , assignment2.name, ModuleItemTypes.ASSIGNMENT.stringVal, assignment2.id.toString()) + + Log.d(PREPARATION_TAG, "Associate '${testPage2.title}' page with module: '${module2.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module2.id, testPage2.title, ModuleItemTypes.PAGE.stringVal, null, pageUrl = testPage2.url) + + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'. Assert that '${course.name}' course is displayed on the Dashboard.") + tokenLogin(teacher) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open '${course.name}' course and navigate to Modules Page.") + dashboardPage.openCourse(course.name) + courseBrowserPage.openModulesTab() + + Log.d(STEP_TAG, "Assert that '${module.name}' and '${module2.name}' modules are displayed and they are unpublished by default. Assert that the '${testPage.title}' page module item is not published and the other module items are published in '${module.name}' module.") + moduleListPage.assertModuleIsDisplayed(module.name) + moduleListPage.assertModuleNotPublished(module.name) + moduleListPage.assertModuleIsDisplayed(module2.name) + moduleListPage.assertModuleNotPublished(module2.name) + moduleListPage.assertModuleItemIsPublished(assignment.name) + moduleListPage.assertModuleItemIsPublished(quiz.title) + moduleListPage.assertModuleItemIsPublished(discussionTopic.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) + + //Upper layer - All Modules and Items + Log.d(STEP_TAG, "Open Module List Page overflow menu and assert that the corresponding menu items are displayed.") + openOverflowMenu() + moduleListPage.assertToolbarMenuItems() + + Log.d(STEP_TAG, "Click on 'Publish all Modules and Items' and confirm it via the publish dialog.") + moduleListPage.clickOnText(R.string.publishAllModulesAndItems) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'All Modules and Items' is displayed as title and the corresponding note also displayed on the Progress Page. Click on 'Done' on the Progress Page once it finished.") + progressPage.assertProgressPageTitle(R.string.allModulesAndItems) + progressPage.assertProgressPageNote(R.string.moduleBulkUpdateNote) + progressPage.clickDone() + + Log.d(STEP_TAG, "Assert that the proper snack bar text is displayed and the '${module.name}' module and all of it's items became published.") + moduleListPage.assertSnackbarText(R.string.allModulesAndAllItemsPublished) + moduleListPage.assertModuleIsPublished(module.name) + moduleListPage.assertModuleItemIsPublished(assignment.name) + moduleListPage.assertModuleItemIsPublished(quiz.title) + moduleListPage.assertModuleItemIsPublished(testPage.title) + moduleListPage.assertModuleItemIsPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Assert that '${module2.name}' module and all of it's items became published.") + moduleListPage.assertModuleIsPublished(module2.name) + moduleListPage.assertModuleItemIsPublished(assignment2.name) + moduleListPage.assertModuleItemIsPublished(testPage2.title) + + Log.d(STEP_TAG, "Open Module List Page overflow menu") + openOverflowMenu() + + Log.d(STEP_TAG, "Click on 'Unpublish all Modules and Items' and confirm it via the unpublish dialog.") + moduleListPage.clickOnText(R.string.unpublishAllModulesAndItems) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'All Modules and Items' is displayed as title on the Progress page. Click on 'Done' on the Progress Page once it finished.") + progressPage.assertProgressPageTitle(R.string.allModulesAndItems) + progressPage.clickDone() + + Log.d(STEP_TAG, "Assert that the proper snack bar text is displayed and the '${module.name}' module and all of it's items became unpublished.") + moduleListPage.assertSnackbarText(R.string.allModulesAndAllItemsUnpublished) + moduleListPage.assertModuleNotPublished(module.name) + moduleListPage.assertModuleItemNotPublished(assignment.name) + moduleListPage.assertModuleItemNotPublished(quiz.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) + moduleListPage.assertModuleItemNotPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Assert that '${module2.name}' module and all of it's items became unpublished.") + moduleListPage.assertModuleNotPublished(module2.name) + moduleListPage.assertModuleItemNotPublished(assignment2.name) + moduleListPage.assertModuleItemNotPublished(testPage2.title) + + Log.d(STEP_TAG, "Open Module List Page overflow menu") + openOverflowMenu() + + Log.d(STEP_TAG, "Click on 'Publish Modules only' and confirm it via the publish dialog.") + moduleListPage.clickOnText(R.string.publishModulesOnly) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'All Modules' title is displayed on the Progress page. Click on 'Done' on the Progress Page once it finished.") + progressPage.assertProgressPageTitle(R.string.allModules) + progressPage.clickDone() + + Log.d(STEP_TAG, "Assert that the proper snack bar text is displayed and only the '${module.name}' module became published, but it's items remaining unpublished.") + moduleListPage.assertSnackbarText(R.string.onlyModulesPublished) + moduleListPage.assertModuleIsPublished(module.name) + moduleListPage.assertModuleItemNotPublished(assignment.name) + moduleListPage.assertModuleItemNotPublished(quiz.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) + moduleListPage.assertModuleItemNotPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Assert that '${module2.name}' module became published but all of it's items are remaining unpublished.") + moduleListPage.assertModuleIsPublished(module2.name) + moduleListPage.assertModuleItemNotPublished(assignment2.name) + moduleListPage.assertModuleItemNotPublished(testPage2.title) + + //Middle layer - One Module and Items + + Log.d(STEP_TAG, "Click on '${module.name}' module overflow and assert that the corresponding menu items are displayed.") + moduleListPage.clickItemOverflow(module.name) + moduleListPage.assertModuleMenuItems() + + Log.d(STEP_TAG, "Click on 'Publish Module and all Items' and confirm it via the publish dialog.") + moduleListPage.clickOnText(R.string.publishModuleAndItems) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Selected Modules and Items' is displayed as title on the Progress page. Click on 'Done' on the Progress Page once it finished.") + progressPage.assertProgressPageTitle(R.string.selectedModulesAndItems) + progressPage.clickDone() + + Log.d(STEP_TAG, "Assert that the proper snack bar text is displayed and the '${module.name}' module and all of it's items became published.") + moduleListPage.assertSnackbarText(R.string.moduleAndAllItemsPublished) + moduleListPage.assertModuleIsPublished(module.name) + moduleListPage.assertModuleItemIsPublished(assignment.name) + moduleListPage.assertModuleItemIsPublished(quiz.title) + moduleListPage.assertModuleItemIsPublished(testPage.title) + moduleListPage.assertModuleItemIsPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Click on '${module.name}' module overflow.") + moduleListPage.clickItemOverflow(module.name) + + Log.d(STEP_TAG, "Click on 'Unpublish Module and all Items' and confirm it via the unpublish dialog.") + moduleListPage.clickOnText(R.string.unpublishModuleAndItems) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Selected Modules and Items' is displayed as title on the Progress page. Click on 'Done' on the Progress Page once it finished.") + progressPage.assertProgressPageTitle(R.string.selectedModulesAndItems) + progressPage.clickDone() + + Log.d(STEP_TAG, "Assert that the proper snack bar text is displayed and the '${module.name}' module and all of it's items became unpublished.") + moduleListPage.assertSnackbarText(R.string.moduleAndAllItemsUnpublished) + moduleListPage.assertModuleNotPublished(module.name) + moduleListPage.assertModuleItemNotPublished(assignment.name) + moduleListPage.assertModuleItemNotPublished(quiz.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) + moduleListPage.assertModuleItemNotPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Click on '${module.name}' module overflow.") + moduleListPage.clickItemOverflow(module.name) + + Log.d(STEP_TAG, "Click on 'Publish Module only' and confirm it via the publish dialog.") + moduleListPage.clickOnText(R.string.publishModuleOnly) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + device.waitForWindowUpdate(null, 3000) + device.waitForIdle() + + Log.d(STEP_TAG, "Assert that only the '${module.name}' module became published, but it's items remaining unpublished.") + moduleListPage.assertModuleIsPublished(module.name) + moduleListPage.assertModuleItemNotPublished(assignment.name) + moduleListPage.assertModuleItemNotPublished(quiz.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) + moduleListPage.assertModuleItemNotPublished(discussionTopic.title) + + //Bottom layer - One module item + + Log.d(STEP_TAG, "Click on '${assignment.name}' assignment's overflow menu and publish it. Confirm the publish via the publish dialog.") + moduleListPage.clickItemOverflow(assignment.name) + moduleListPage.clickOnText(R.string.publishModuleItemAction) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item published' snack bar has displayed and the '${assignment.name}' assignment became published.") + moduleListPage.assertSnackbarText(R.string.moduleItemPublished) + moduleListPage.assertModuleItemIsPublished(assignment.name) + + Log.d(STEP_TAG, "Click on '${quiz.title}' quiz's overflow menu and publish it. Confirm the publish via the publish dialog.") + moduleListPage.clickItemOverflow(quiz.title) + moduleListPage.clickOnText(R.string.publishModuleItemAction) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item published' snack bar has displayed and the '${quiz.title}' quiz became published.") + moduleListPage.assertSnackbarText(R.string.moduleItemPublished) + moduleListPage.assertModuleItemIsPublished(quiz.title) + + Log.d(STEP_TAG, "Click on '${testPage.title}' page's overflow menu and publish it. Confirm the publish via the publish dialog.") + moduleListPage.clickItemOverflow(testPage.title) + moduleListPage.clickOnText(R.string.publishModuleItemAction) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item published' snack bar has displayed and the '${testPage.title}' page module item became published.") + moduleListPage.assertSnackbarText(R.string.moduleItemPublished) + moduleListPage.assertModuleItemIsPublished(assignment.name) + + Log.d(STEP_TAG, "Click on '${discussionTopic.title}' discussion topic's overflow menu and publish it. Confirm the publish via the publish dialog.") + moduleListPage.clickItemOverflow(discussionTopic.title) + moduleListPage.clickOnText(R.string.publishModuleItemAction) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item published' snack bar has displayed and the '${discussionTopic.title}' discussion topic became published.") + moduleListPage.assertSnackbarText(R.string.moduleItemPublished) + moduleListPage.assertModuleItemIsPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Click on '${assignment.name}' assignment's overflow menu and unpublish it. Confirm the unpublish via the unpublish dialog.") + moduleListPage.clickItemOverflow(assignment.name) + moduleListPage.clickOnText(R.string.unpublishModuleItemAction) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item unpublished' snack bar has displayed and the '${assignment.name}' assignment became unpublished.") + moduleListPage.assertSnackbarText(R.string.moduleItemUnpublished) + moduleListPage.assertModuleItemNotPublished(assignment.name) + + Log.d(STEP_TAG, "Click on '${quiz.title}' quiz's overflow menu and unpublish it. Confirm the unpublish via the unpublish dialog.") + moduleListPage.clickItemOverflow(quiz.title) + moduleListPage.clickOnText(R.string.unpublishModuleItemAction) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item unpublished' snack bar has displayed and the '${quiz.title}' quiz became unpublished.") + moduleListPage.assertSnackbarText(R.string.moduleItemUnpublished) + moduleListPage.assertModuleItemNotPublished(quiz.title) + + Log.d(STEP_TAG, "Click on '${testPage.title}' page overflow menu and unpublish it. Confirm the unpublish via the unpublish dialog.") + moduleListPage.clickItemOverflow(testPage.title) + moduleListPage.clickOnText(R.string.unpublishModuleItemAction) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item unpublished' snack bar has displayed and the '${testPage.title}' page module item became unpublished.") + moduleListPage.assertSnackbarText(R.string.moduleItemUnpublished) + moduleListPage.assertModuleItemNotPublished(testPage.title) + + Log.d(STEP_TAG, "Click on '${discussionTopic.title}' discussion topic's overflow menu and unpublish it. Confirm the unpublish via the unpublish dialog.") + moduleListPage.clickItemOverflow(discussionTopic.title) + moduleListPage.clickOnText(R.string.unpublishModuleItemAction) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item unpublished' snack bar has displayed and the '${discussionTopic.title}' discussion topic became unpublished.") + moduleListPage.assertSnackbarText(R.string.moduleItemUnpublished) + moduleListPage.assertModuleItemNotPublished(discussionTopic.title) + } } \ No newline at end of file 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 eb42f20fbd..4535f058d9 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 @@ -9,9 +9,6 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.PagesApi -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel -import com.instructure.dataseeding.model.PageApiModel import com.instructure.teacher.ui.pages.WebViewTextCheck import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.seedData @@ -21,6 +18,7 @@ import org.junit.Test @HiltAndroidTest class PagesE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -35,87 +33,87 @@ class PagesE2ETest : TeacherTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Create an unpublished page for course: ${course.name}.") - val testPage1 = createCoursePage(course, teacher, published = false, frontPage = false, body = "

Unpublished Page Text

") + Log.d(PREPARATION_TAG,"Create an unpublished page for course: '${course.name}'.") + val unpublishedPage = PagesApi.createCoursePage(course.id, teacher.token, published = false, frontPage = false, body = "

Unpublished Page Text

") - Log.d(PREPARATION_TAG,"Create a published page for course: ${course.name}.") - val testPage2 = createCoursePage(course, teacher, published = true, frontPage = false, body = "

Regular Page Text

") + Log.d(PREPARATION_TAG,"Create a published page for course: '${course.name}'.") + val publishedPage = PagesApi.createCoursePage(course.id, teacher.token, published = true, frontPage = false, body = "

Regular Page Text

") - Log.d(PREPARATION_TAG,"Create a front page for course: ${course.name}.") - val testPage3 = createCoursePage(course, teacher, published = true, frontPage = true, body = "

Front Page Text

") + Log.d(PREPARATION_TAG,"Create a front page for course: '${course.name}'.") + val frontPage = PagesApi.createCoursePage(course.id, teacher.token, published = true, frontPage = true, body = "

Front Page Text

") - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Pages Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Pages Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openPagesTab() - Log.d(STEP_TAG,"Assert that ${testPage1.title} page is displayed and it is really unpublished.") - pageListPage.assertPageDisplayed(testPage1.title) - pageListPage.assertPageIsUnpublished(testPage1.title) + Log.d(STEP_TAG,"Assert that '${unpublishedPage.title}' page is displayed and it is really unpublished.") + pageListPage.assertPageDisplayed(unpublishedPage.title) + pageListPage.assertPageIsUnpublished(unpublishedPage.title) - Log.d(STEP_TAG,"Assert that ${testPage2.title} page is displayed and it is really published.") - pageListPage.assertPageDisplayed(testPage2.title) - pageListPage.assertPageIsPublished(testPage2.title) + Log.d(STEP_TAG,"Assert that '${publishedPage.title}' page is displayed and it is really published.") + pageListPage.assertPageDisplayed(publishedPage.title) + pageListPage.assertPageIsPublished(publishedPage.title) - Log.d(STEP_TAG,"Assert that ${testPage3.title} page is displayed and it is really a front page and published.") - pageListPage.assertPageDisplayed(testPage3.title) - pageListPage.assertPageIsPublished(testPage3.title) - pageListPage.assertFrontPageDisplayed(testPage3.title) + Log.d(STEP_TAG,"Assert that '${frontPage.title}' page is displayed and it is really a front page and published.") + pageListPage.assertPageDisplayed(frontPage.title) + pageListPage.assertPageIsPublished(frontPage.title) + pageListPage.assertFrontPageDisplayed(frontPage.title) - Log.d(STEP_TAG,"Open ${testPage2.title} page. Assert that it is really a regular published page via web view assertions.") - pageListPage.openPage(testPage2.title) + Log.d(STEP_TAG,"Open '${publishedPage.title}' page. Assert that it is really a regular published page via web view assertions.") + pageListPage.openPage(publishedPage.title) editPageDetailsPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Regular Page Text")) Log.d(STEP_TAG,"Navigate back to Pages page.") Espresso.pressBack() - Log.d(STEP_TAG,"Open ${testPage3.title} page. Assert that it is really a front (published) page via web view assertions.") - pageListPage.openPage(testPage3.title) + Log.d(STEP_TAG,"Open '${frontPage.title}' page. Assert that it is really a front (published) page via web view assertions.") + pageListPage.openPage(frontPage.title) editPageDetailsPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Front Page Text")) Log.d(STEP_TAG,"Navigate back to Pages page.") Espresso.pressBack() - Log.d(STEP_TAG,"Open ${testPage1.title} page. Assert that it is really an unpublished page via web view assertions.") - pageListPage.openPage(testPage1.title) + Log.d(STEP_TAG,"Open '${unpublishedPage.title}' page. Assert that it is really an unpublished page via web view assertions.") + pageListPage.openPage(unpublishedPage.title) editPageDetailsPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Unpublished Page Text")) Espresso.pressBack() val editedUnpublishedPageName = "Page still unpublished" - Log.d(STEP_TAG,"Open and edit the ${testPage1.title} page and set $editedUnpublishedPageName page name as new value. Click on 'Save' and navigate back.") - pageListPage.openPage(testPage1.title) + Log.d(STEP_TAG,"Open and edit the '${unpublishedPage.title}' page and set '$editedUnpublishedPageName' page name as new value. Click on 'Save' and navigate back.") + pageListPage.openPage(unpublishedPage.title) editPageDetailsPage.openEdit() editPageDetailsPage.editPageName(editedUnpublishedPageName) editPageDetailsPage.savePage() Espresso.pressBack() - Log.d(STEP_TAG,"Assert that the page name has been changed to $editedUnpublishedPageName.") + Log.d(STEP_TAG,"Assert that the page name has been changed to '$editedUnpublishedPageName'.") pageListPage.assertPageIsUnpublished(editedUnpublishedPageName) - Log.d(STEP_TAG,"Open ${testPage2.title} page and Edit it. Set it as a front page and click on 'Save'. Navigate back.") - pageListPage.openPage(testPage2.title) + Log.d(STEP_TAG,"Open '${publishedPage.title}' page and Edit it. Set it as a front page and click on 'Save'. Navigate back.") + pageListPage.openPage(publishedPage.title) editPageDetailsPage.openEdit() editPageDetailsPage.toggleFrontPage() editPageDetailsPage.savePage() Espresso.pressBack() - Log.d(STEP_TAG,"Assert that ${testPage2.title} is displayed as a front page.") - pageListPage.assertFrontPageDisplayed(testPage2.title) + Log.d(STEP_TAG,"Assert that '${publishedPage.title}' is displayed as a front page.") + pageListPage.assertFrontPageDisplayed(publishedPage.title) - Log.d(STEP_TAG,"Open $editedUnpublishedPageName page and Edit it. Set it as a front page and click on 'Save'. Navigate back.") + Log.d(STEP_TAG,"Open '$editedUnpublishedPageName' page and Edit it. Set it as a front page and click on 'Save'. Navigate back.") pageListPage.openPage(editedUnpublishedPageName) editPageDetailsPage.openEdit() editPageDetailsPage.togglePublished() editPageDetailsPage.savePage() Espresso.pressBack() - Log.d(STEP_TAG,"Assert that $testPage2 is published.") - pageListPage.assertPageIsPublished(testPage2.title) + Log.d(STEP_TAG,"Assert that '$publishedPage' is published.") + pageListPage.assertPageIsPublished(publishedPage.title) - Log.d(STEP_TAG,"Open ${testPage3.title} page and Edit it. Unpublish it and remove 'Front page' from it.") - pageListPage.openPage(testPage3.title) + Log.d(STEP_TAG,"Open '${frontPage.title}' page and Edit it. Unpublish it and remove 'Front page' from it.") + pageListPage.openPage(frontPage.title) editPageDetailsPage.openEdit() editPageDetailsPage.togglePublished() editPageDetailsPage.toggleFrontPage() @@ -123,13 +121,13 @@ class PagesE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert that a front page cannot be unpublished.") editPageDetailsPage.unableToSaveUnpublishedFrontPage() - Log.d(STEP_TAG,"Publish ${testPage3.title} page again. Click on 'Save' and navigate back-") + Log.d(STEP_TAG,"Publish '${frontPage.title}' page again. Click on 'Save' and navigate back-") editPageDetailsPage.togglePublished() editPageDetailsPage.savePage() Espresso.pressBack() - Log.d(STEP_TAG,"Assert that ${testPage2.title} is displayed as a front page.") - pageListPage.assertFrontPageDisplayed(testPage2.title) + Log.d(STEP_TAG,"Assert that '${publishedPage.title}' is displayed as a front page.") + pageListPage.assertFrontPageDisplayed(publishedPage.title) Log.d(STEP_TAG,"Click on '+' icon on the UI to create a new page.") pageListPage.clickOnCreateNewPage() @@ -159,20 +157,4 @@ class PagesE2ETest : TeacherTest() { pageListPage.assertPageCount(4) } - private fun createCoursePage( - course: CourseApiModel, - teacher: CanvasUserApiModel, - published: Boolean = true, - frontPage: Boolean = false, - body: String = EMPTY_STRING - ): PageApiModel { - return PagesApi.createCoursePage( - courseId = course.id, - published = published, - frontPage = frontPage, - token = teacher.token, - body = body - ) - } - } \ No newline at end of file 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 98272c9829..f5b64c3b61 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 @@ -25,9 +25,6 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.GroupsApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow @@ -46,6 +43,7 @@ import java.lang.Thread.sleep @HiltAndroidTest class PeopleE2ETest: TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -69,13 +67,13 @@ class PeopleE2ETest: TeacherTest() { val group = GroupsApi.createGroup(groupCategory.id, teacher.token) val group2 = GroupsApi.createGroup(groupCategory2.id, teacher.token) - Log.d(PREPARATION_TAG,"Create group membership for ${gradedStudent.name} student to '${group.name}' group.") + Log.d(PREPARATION_TAG,"Create group membership for '${gradedStudent.name}' student to '${group.name}' group.") GroupsApi.createGroupMembership(group.id, gradedStudent.id, teacher.token) - Log.d(PREPARATION_TAG,"Create group membership for ${notGradedStudent.name} student to '${group2.name}' group.") + Log.d(PREPARATION_TAG,"Create group membership for '${notGradedStudent.name}' student to '${group2.name}' group.") GroupsApi.createGroupMembership(group2.id, notGradedStudent.id, teacher.token) - Log.d(PREPARATION_TAG,"Seed a 'Text Entry' assignment for course: ${course.name}.") + Log.d(PREPARATION_TAG,"Seed a 'Text Entry' assignment for course: '${course.name}'.") val assignments = seedAssignments( courseId = course.id, dueAt = 1.days.fromNow.iso8601, @@ -84,7 +82,7 @@ class PeopleE2ETest: TeacherTest() { pointsPossible = 10.0 ) - Log.d(PREPARATION_TAG,"Seed a submission for ${assignments[0].name} assignment.") + Log.d(PREPARATION_TAG,"Seed a submission for '${assignments[0].name}' assignment.") seedAssignmentSubmission( submissionSeeds = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, @@ -95,13 +93,13 @@ class PeopleE2ETest: TeacherTest() { studentToken = gradedStudent.token ) - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${assignments[0].name} assignment.") - gradeSubmission(teacher, course, assignments, gradedStudent) + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${assignments[0].name}' assignment.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignments[0].id, gradedStudent.id, postedGrade = "10") - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) - Log.d(STEP_TAG,"Open ${course.name} course and navigate to People Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to People Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openPeopleTab() @@ -118,7 +116,7 @@ class PeopleE2ETest: TeacherTest() { personContextPage.assertDisplaysCourseInfo(course) personContextPage.assertSectionNameView(PersonContextPage.UserRole.OBSERVER) - Log.d(STEP_TAG,"Navigate back and click on ${notGradedStudent.name} student and assert that the NOT GRADED student course info and the corresponding section name is displayed are displayed properly on Context Page.") + Log.d(STEP_TAG,"Navigate back and click on '${notGradedStudent.name}' student and assert that the NOT GRADED student course info and the corresponding section name is displayed are displayed properly on Context Page.") Espresso.pressBack() peopleListPage.assertPersonRole(notGradedStudent.name, PeopleListPage.UserRole.STUDENT) peopleListPage.clickPerson(notGradedStudent) @@ -128,7 +126,7 @@ class PeopleE2ETest: TeacherTest() { studentContextPage.assertStudentGrade("--") studentContextPage.assertStudentSubmission("--") - Log.d(STEP_TAG,"Navigate back and click on ${gradedStudent.name} student." + + Log.d(STEP_TAG,"Navigate back and click on '${gradedStudent.name}' student." + "Assert that '${gradedStudent.name}' graded student's info," + "and the '${course.name}' course's info are displayed properly on the Context Page.") Espresso.pressBack() @@ -145,7 +143,7 @@ class PeopleE2ETest: TeacherTest() { studentContextPage.clickOnNewMessageButton() val subject = "Test Subject" - Log.d(STEP_TAG,"Fill in the 'Subject' field with the value: $subject. Add some message text and click on 'Send' (aka. 'Arrow') button.") + 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() @@ -216,19 +214,4 @@ class PeopleE2ETest: TeacherTest() { inboxPage.assertHasConversation() } - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignments: List, - gradedStudent: CanvasUserApiModel - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignments[0].id, - studentId = gradedStudent.id, - postedGrade = "10", - excused = false - ) - } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/QuizE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/QuizE2ETest.kt index 66298f5d5f..2864daabc4 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/QuizE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/QuizE2ETest.kt @@ -36,6 +36,7 @@ import org.junit.Test @HiltAndroidTest class QuizE2ETest: TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -51,63 +52,48 @@ class QuizE2ETest: TeacherTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Quizzes Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Quizzes Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openQuizzesTab() Log.d(STEP_TAG,"Assert that there is no quiz displayed on the page.") quizListPage.assertDisplaysNoQuizzesView() - Log.d(PREPARATION_TAG,"Seed a quiz for the ${course.name} course. Also, seed a question into the quiz and publish it.") - val testQuizList = seedQuizzes( - courseId = course.id, - withDescription = true, - dueAt = 3.days.fromNow.iso8601, - teacherToken = teacher.token, - published = false - ) - - seedQuizQuestion( - courseId = course.id, - quizId = testQuizList.quizList[0].id, - teacherToken = teacher.token - ) - - Log.d(STEP_TAG,"Refresh the page. Assert that the quiz is there and click on the previously seeded quiz: ${testQuizList.quizList[0].title}.") + Log.d(PREPARATION_TAG,"Seed a quiz for the '${course.name}' course. Also, seed a question into the quiz and publish it.") + val testQuizList = seedQuizzes(courseId = course.id, withDescription = true, dueAt = 3.days.fromNow.iso8601, teacherToken = teacher.token, published = false) + seedQuizQuestion(courseId = course.id, quizId = testQuizList.quizList[0].id, teacherToken = teacher.token) + + Log.d(STEP_TAG,"Refresh the page. Assert that the quiz is there and click on the previously seeded quiz: '${testQuizList.quizList[0].title}'.") quizListPage.refresh() quizListPage.clickQuiz(testQuizList.quizList[0].title) - Log.d(STEP_TAG,"Assert that ${testQuizList.quizList[0].title} quiz is 'Not Submitted' and it is unpublished.") + Log.d(STEP_TAG,"Assert that '${testQuizList.quizList[0].title}' quiz is 'Not Submitted' and it is unpublished.") quizDetailsPage.assertNotSubmitted() quizDetailsPage.assertQuizUnpublished() val newQuizTitle = "This is a new quiz" - Log.d(STEP_TAG,"Open 'Edit' page and edit the ${testQuizList.quizList[0].title} quiz's title to: $newQuizTitle.") + Log.d(STEP_TAG,"Open 'Edit' page and edit the '${testQuizList.quizList[0].title}' quiz's title to: '$newQuizTitle'.") quizDetailsPage.openEditPage() editQuizDetailsPage.editQuizTitle(newQuizTitle) - Log.d(STEP_TAG,"Assert that the quiz name has been changed to: $newQuizTitle.") + Log.d(STEP_TAG,"Assert that the quiz name has been changed to: '$newQuizTitle'.") quizDetailsPage.assertQuizNameChanged(newQuizTitle) - Log.d(STEP_TAG,"Open 'Edit' page and switch on the 'Published' checkbox, so publish the $newQuizTitle quiz. Click on 'Save'.") + Log.d(STEP_TAG,"Open 'Edit' page and switch on the 'Published' checkbox, so publish the '$newQuizTitle' quiz. Click on 'Save'.") quizDetailsPage.openEditPage() editQuizDetailsPage.switchPublish() editQuizDetailsPage.saveQuiz() - Log.d(STEP_TAG,"Refresh the page. Assert that $newQuizTitle quiz has been unpublished.") + Log.d(STEP_TAG,"Refresh the page. Assert that '$newQuizTitle' quiz has been unpublished.") quizDetailsPage.refresh() quizDetailsPage.assertQuizPublished() - Log.d(PREPARATION_TAG,"Submit the ${testQuizList.quizList[0].title} quiz.") - seedQuizSubmission( - courseId = course.id, - quizId = testQuizList.quizList[0].id, - studentToken = student.token - ) + Log.d(PREPARATION_TAG,"Submit the '${testQuizList.quizList[0].title}' quiz.") + seedQuizSubmission(courseId = course.id, quizId = testQuizList.quizList[0].id, studentToken = student.token) Log.d(STEP_TAG,"Refresh the page. Assert that it needs grading because of the previous submission.") quizListPage.refresh() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SettingsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SettingsE2ETest.kt index d7eeea79ac..dad19d8bac 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SettingsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SettingsE2ETest.kt @@ -48,7 +48,7 @@ class SettingsE2ETest : TeacherTest() { val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -64,11 +64,11 @@ class SettingsE2ETest : TeacherTest() { profileSettingsPage.clickEditPencilIcon() val newUserName = "John Doe" - Log.d(STEP_TAG, "Edit username to: $newUserName. Click on 'Save' button.") + Log.d(STEP_TAG, "Edit username to: '$newUserName'. Click on 'Save' button.") editProfileSettingsPage.editUserName(newUserName) editProfileSettingsPage.clickOnSave() - Log.d(STEP_TAG, "Assert that the username has been changed to $newUserName on the Profile Settings Page.") + Log.d(STEP_TAG, "Assert that the username has been changed to '$newUserName' on the Profile Settings Page.") try { Log.d(STEP_TAG, "Check if the user has landed on Settings Page. If yes, navigate back to Profile Settings Page.") //Sometimes in Bitrise it's working different than locally, because in Bitrise sometimes the user has been navigated to Settings Page after saving a new name, @@ -94,13 +94,13 @@ class SettingsE2ETest : TeacherTest() { Log.d(STEP_TAG, "Press back button (without saving). The goal is to navigate back to the Profile Settings Page.") Espresso.pressBack() - Log.d(STEP_TAG, "Assert that the username value remained $newUserName.") + Log.d(STEP_TAG, "Assert that the username value remained '$newUserName'.") profileSettingsPage.assertUserNameIs(newUserName) } catch (e: NoMatchingViewException) { Log.d(STEP_TAG, "Press back button (without saving). The goal is to navigate back to the Profile Settings Page.") Espresso.pressBack() - Log.d(STEP_TAG, "Assert that the username value remained $newUserName.") + Log.d(STEP_TAG, "Assert that the username value remained '$newUserName'.") profileSettingsPage.assertUserNameIs(newUserName) } } @@ -109,12 +109,13 @@ class SettingsE2ETest : TeacherTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.SETTINGS, TestCategory.E2E) fun testDarkModeE2E() { + Log.d(PREPARATION_TAG, "Seeding data.") val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -134,7 +135,7 @@ class SettingsE2ETest : TeacherTest() { Espresso.pressBack() dashboardPage.assertCourseLabelTextColor("#FFFFFFFF") - Log.d(STEP_TAG,"Select ${course.name} course and assert on the Course Browser Page that the tabs has the proper text color (which is used in Dark mode).") + Log.d(STEP_TAG,"Select '${course.name}' course and assert on the Course Browser Page that the tabs has the proper text color (which is used in Dark mode).") dashboardPage.openCourse(course.name) courseBrowserPage.assertTabLabelTextColor("Announcements","#FFFFFFFF") courseBrowserPage.assertTabLabelTextColor("Assignments","#FFFFFFFF") @@ -163,7 +164,7 @@ class SettingsE2ETest : TeacherTest() { val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -185,7 +186,7 @@ class SettingsE2ETest : TeacherTest() { val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -197,13 +198,13 @@ class SettingsE2ETest : TeacherTest() { settingsPage.openAboutPage() aboutPage.assertPageObjects() - Log.d(STEP_TAG,"Check that domain is equal to: ${teacher.domain} (teacher's domain).") + Log.d(STEP_TAG,"Check that domain is equal to: '${teacher.domain}' (teacher's domain).") aboutPage.domainIs(teacher.domain) - Log.d(STEP_TAG,"Check that Login ID is equal to: ${teacher.loginId} (teacher's Login ID).") + Log.d(STEP_TAG,"Check that Login ID is equal to: '${teacher.loginId}' (teacher's Login ID).") aboutPage.loginIdIs(teacher.loginId) - Log.d(STEP_TAG,"Check that e-mail is equal to: ${teacher.loginId} (teacher's Login ID).") + Log.d(STEP_TAG,"Check that e-mail is equal to: '${teacher.loginId}' (teacher's Login ID).") aboutPage.emailIs(teacher.loginId) Log.d(STEP_TAG,"Assert that the Instructure company logo has been displayed on the About page.") @@ -219,7 +220,7 @@ class SettingsE2ETest : TeacherTest() { val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -244,7 +245,7 @@ class SettingsE2ETest : TeacherTest() { val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -253,7 +254,7 @@ class SettingsE2ETest : TeacherTest() { Log.d(PREPARATION_TAG,"Capture the initial remote config values.") val initialValues = mutableMapOf() - RemoteConfigParam.values().forEach {param -> initialValues.put(param.rc_name, RemoteConfigUtils.getString(param))} + RemoteConfigParam.values().forEach { param -> initialValues[param.rc_name] = RemoteConfigUtils.getString(param) } Log.d(STEP_TAG,"Navigate to Remote Config Params Page.") settingsPage.openRemoteConfigParamsPage() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt index acf857613d..8231b11cb8 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt @@ -30,9 +30,6 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.refresh import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow @@ -49,6 +46,7 @@ import org.junit.Test @HiltAndroidTest class SpeedGraderE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -66,7 +64,7 @@ class SpeedGraderE2ETest : TeacherTest() { val gradedStudent = data.studentsList[1] val noSubStudent = data.studentsList[2] - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") val assignment = seedAssignments( courseId = course.id, dueAt = 1.days.fromNow.iso8601, @@ -75,7 +73,7 @@ class SpeedGraderE2ETest : TeacherTest() { pointsPossible = 15.0 ) - Log.d(PREPARATION_TAG,"Seed a submission for ${assignment[0].name} assignment with ${student.name} student.") + Log.d(PREPARATION_TAG,"Seed a submission for '${assignment[0].name}' assignment with '${student.name}' student.") seedAssignmentSubmission( submissionSeeds = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, @@ -86,7 +84,7 @@ class SpeedGraderE2ETest : TeacherTest() { studentToken = student.token ) - Log.d(PREPARATION_TAG,"Seed a submission for ${assignment[0].name} assignment with ${gradedStudent.name} student.") + Log.d(PREPARATION_TAG,"Seed a submission for '${assignment[0].name}' assignment with '${gradedStudent.name}' student.") seedAssignmentSubmission( submissionSeeds = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, @@ -97,32 +95,32 @@ class SpeedGraderE2ETest : TeacherTest() { studentToken = gradedStudent.token ) - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${gradedStudent.name} student.") - gradeSubmission(teacher, course, assignment, gradedStudent) + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${gradedStudent.name}' student.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment[0].id, gradedStudent.id, postedGrade = "15") - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Assignments Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Assignments Page.") dashboardPage.openCourse(course) courseBrowserPage.openAssignmentsTab() - Log.d(STEP_TAG,"Click on ${assignment[0].name} assignment and assert that that there is one 'Needs Grading' submission (for ${noSubStudent.name} student) and one 'Not Submitted' submission (for ${student.name} student. ") + Log.d(STEP_TAG,"Click on '${assignment[0].name}' assignment and assert that that there is one 'Needs Grading' submission for '${noSubStudent.name}' student and one 'Not Submitted' submission for '${student.name}' student.") assignmentListPage.clickAssignment(assignment[0]) assignmentDetailsPage.assertNeedsGrading(actual = 1, outOf = 3) assignmentDetailsPage.assertNotSubmitted(actual = 1, outOf = 3) - Log.d(STEP_TAG,"Open 'Not Submitted' submissions and assert that the submission of ${noSubStudent.name} student is displayed. Navigate back.") + Log.d(STEP_TAG,"Open 'Not Submitted' submissions and assert that the submission of '${noSubStudent.name}' student is displayed. Navigate back.") assignmentDetailsPage.openNotSubmittedSubmissions() assignmentSubmissionListPage.assertHasStudentSubmission(noSubStudent) Espresso.pressBack() - Log.d(STEP_TAG,"Open 'Graded' submissions and assert that the submission of ${gradedStudent.name} student is displayed. Navigate back.") + Log.d(STEP_TAG,"Open 'Graded' submissions and assert that the submission of '${gradedStudent.name}' student is displayed. Navigate back.") assignmentDetailsPage.openGradedSubmissions() assignmentSubmissionListPage.assertHasStudentSubmission(gradedStudent) Espresso.pressBack() - Log.d(STEP_TAG,"Open (all) submissions and assert that the submission of ${student.name} student is displayed.") + Log.d(STEP_TAG,"Open (all) submissions and assert that the submission of '${student.name}' student is displayed.") assignmentDetailsPage.openSubmissionsPage() assignmentSubmissionListPage.clickSubmission(student) speedGraderPage.assertDisplaysTextSubmissionViewWithStudentName(student.name) @@ -132,7 +130,7 @@ class SpeedGraderE2ETest : TeacherTest() { speedGraderGradePage.openGradeDialog() val grade = "10" - Log.d(STEP_TAG,"Enter $grade as the new grade and assert that it has applied. Navigate back and refresh the page.") + Log.d(STEP_TAG,"Enter '$grade' as the new grade and assert that it has applied. Navigate back and refresh the page.") speedGraderGradePage.enterNewGrade(grade) speedGraderGradePage.assertHasGrade(grade) Espresso.pressBack() @@ -165,7 +163,7 @@ class SpeedGraderE2ETest : TeacherTest() { Log.d(STEP_TAG, "Navigate back assignment's details page.") Espresso.pressBack() - Log.d(STEP_TAG,"Open (all) submissions and assert that the submission of ${student.name} student is displayed.") + Log.d(STEP_TAG,"Open (all) submissions and assert that the submission of '${student.name}' student is displayed.") assignmentDetailsPage.openSubmissionsPage() Log.d(STEP_TAG, "Click on 'Post Policies' (eye) icon.") @@ -191,19 +189,4 @@ class SpeedGraderE2ETest : TeacherTest() { assignmentSubmissionListPage.assertGradesHidden(student.name) } - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignment: List, - gradedStudent: CanvasUserApiModel - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignment[0].id, - studentId = gradedStudent.id, - postedGrade = "15", - excused = false - ) - } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SyllabusE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SyllabusE2ETest.kt index e64467cbb9..c5e7d3772e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SyllabusE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SyllabusE2ETest.kt @@ -2,20 +2,25 @@ package com.instructure.teacher.ui.e2e import android.util.Log import com.instructure.canvas.espresso.E2E -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.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData -import com.instructure.teacher.ui.utils.* +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.teacher.ui.utils.TeacherTest +import com.instructure.teacher.ui.utils.seedAssignments +import com.instructure.teacher.ui.utils.seedData +import com.instructure.teacher.ui.utils.seedQuizzes +import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @HiltAndroidTest class SyllabusE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -30,54 +35,41 @@ class SyllabusE2ETest : TeacherTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Syllabus Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Syllabus Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openSyllabus() Log.d(STEP_TAG,"Assert that empty view is displayed.") syllabusPage.assertEmptyView() - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val assignment = seedAssignments( - courseId = course.id, - dueAt = 1.days.fromNow.iso8601, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - teacherToken = teacher.token, - pointsPossible = 15.0, - withDescription = true - ) - - Log.d(PREPARATION_TAG,"Seed a quiz for the ${course.name} course.") - val quiz = seedQuizzes( - courseId = course.id, - withDescription = true, - published = true, - teacherToken = teacher.token, - dueAt = 1.days.fromNow.iso8601 - ) - - Log.d(STEP_TAG,"Refresh the Syllabus page and assert that the ${assignment[0].name} assignment and ${quiz.quizList[0].title} quiz are displayed as syllabus items.") + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${course.name}' course.") + val assignment = seedAssignments(courseId = course.id, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), teacherToken = teacher.token, pointsPossible = 15.0, withDescription = true) + + Log.d(PREPARATION_TAG,"Seed a quiz for the '${course.name}' course.") + val quiz = seedQuizzes(courseId = course.id, withDescription = true, published = true, teacherToken = teacher.token, dueAt = 1.days.fromNow.iso8601) + + Log.d(STEP_TAG,"Refresh the Syllabus page and assert that the '${assignment[0].name}' assignment and '${quiz.quizList[0].title}' quiz are displayed as syllabus items.") syllabusPage.refresh() syllabusPage.assertItemDisplayed(assignment[0].name) syllabusPage.assertItemDisplayed(quiz.quizList[0].title) Log.d(STEP_TAG,"Refresh the Syllabus page. Click on 'Pencil' (aka. 'Edit') icon.") - syllabusPage.refresh() syllabusPage.openEditSyllabus() var syllabusBody = "Syllabus Body" - Log.d(STEP_TAG,"Edit syllabus description (aka. 'Syllabus Body') by adding new value to it: $syllabusBody. Click on 'Save'.") + + Log.d(STEP_TAG,"Edit syllabus description (aka. 'Syllabus Body') by adding new value to it: '$syllabusBody'. Click on 'Save'.") editSyllabusPage.editSyllabusBody(syllabusBody) editSyllabusPage.saveSyllabusEdit() Log.d(STEP_TAG,"Assert that the previously made modifications has been applied on the syllabus.") syllabusPage.assertDisplaysSyllabus(syllabusBody = syllabusBody, shouldDisplayTabs = true) - Log.d(STEP_TAG,"Select 'Summary' Tab and assert that the ${assignment[0].name} assignment and ${quiz.quizList[0].title} quiz are displayed.") + Log.d(STEP_TAG,"Select 'Summary' Tab and assert that the '${assignment[0].name}' assignment and '${quiz.quizList[0].title}' quiz are displayed.") syllabusPage.selectSummaryTab() syllabusPage.assertItemDisplayed(assignment[0].name) syllabusPage.assertItemDisplayed(quiz.quizList[0].title) @@ -87,7 +79,7 @@ class SyllabusE2ETest : TeacherTest() { syllabusBody = "Edited Syllabus Body" syllabusPage.openEditSyllabus() - Log.d(STEP_TAG,"Edit syllabus description (aka. 'Syllabus Body') by adding new value to it: $syllabusBody. Toggle 'Show course summary'. Click on 'Save'.") + Log.d(STEP_TAG,"Edit syllabus description (aka. 'Syllabus Body') by adding new value to it: '$syllabusBody'. Toggle 'Show course summary'. Click on 'Save'.") editSyllabusPage.editSyllabusBody(syllabusBody) editSyllabusPage.editSyllabusToggleShowSummary() editSyllabusPage.saveSyllabusEdit() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/TodoE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/TodoE2ETest.kt index bd6f0e0e61..40f3116be6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/TodoE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/TodoE2ETest.kt @@ -18,18 +18,15 @@ package com.instructure.teacher.ui.e2e import android.util.Log import com.instructure.canvas.espresso.E2E +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel 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.canvas.espresso.FeatureCategory -import com.instructure.canvas.espresso.Priority -import com.instructure.canvas.espresso.TestCategory -import com.instructure.canvas.espresso.TestMetaData import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.seedAssignmentSubmission import com.instructure.teacher.ui.utils.seedAssignments @@ -40,6 +37,7 @@ import org.junit.Test @HiltAndroidTest class TodoE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -55,27 +53,17 @@ class TodoE2ETest : TeacherTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val assignments = seedAssignments( - courseId = course.id, - dueAt = 1.days.fromNow.iso8601, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - teacherToken = teacher.token, - pointsPossible = 15.0 - ) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val assignments = seedAssignments(courseId = course.id, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), teacherToken = teacher.token, pointsPossible = 15.0) - Log.d(PREPARATION_TAG,"Seed a submission for ${assignments[0].name} assignment with ${student.name} student.") + Log.d(PREPARATION_TAG,"Seed a submission for '${assignments[0].name}' assignment with '${student.name}' student.") seedAssignmentSubmission( submissionSeeds = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY - )), - assignmentId = assignments[0].id, - courseId = course.id, - studentToken = student.token - ) + )), assignmentId = assignments[0].id, courseId = course.id, studentToken = student.token) - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -83,33 +71,18 @@ class TodoE2ETest : TeacherTest() { dashboardPage.openTodo() todoPage.waitForRender() - Log.d(STEP_TAG,"Assert that the previously seeded ${assignments[0].name} assignment is displayed as a To Do element for the ${course.name} course." + + Log.d(STEP_TAG,"Assert that the previously seeded '${assignments[0].name}' assignment is displayed as a To Do element for the '${course.name}' course." + "Assert that the '1 Needs Grading' text is under the corresponding assignment's details, and assert that the To Do element count is 1.") todoPage.assertTodoElementDetailsDisplayed(course.name) todoPage.assertNeedsGradingCountOfTodoElement(assignments[0].name, 1) todoPage.assertTodoElementCount(1) - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${student.name} student.") - gradeSubmission(teacher, course, assignments, student) + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${student.name}' student.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignments[0].id, student.id, postedGrade = "15") Log.d(STEP_TAG,"Refresh the To Do Page. Assert that the empty view is displayed so that the To Do has disappeared because it has been graded.") todoPage.refresh() todoPage.assertEmptyView() } - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignment: List, - student: CanvasUserApiModel - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignment[0].id, - studentId = student.id, - postedGrade = "15", - excused = false - ) - } } \ No newline at end of file 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 f8a5319490..51a70e4d93 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 @@ -17,6 +17,7 @@ package com.instructure.teacher.ui.pages import androidx.test.InstrumentationRegistry +import androidx.test.espresso.ViewInteraction import androidx.test.espresso.web.assertion.WebViewAssertions import androidx.test.espresso.web.sugar.Web import androidx.test.espresso.web.webdriver.DriverAtoms @@ -34,7 +35,7 @@ import org.hamcrest.Matchers * @constructor Create empty Assignment details page */ @Suppress("unused") -class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { +class AssignmentDetailsPage(val moduleItemInteractions: ModuleItemInteractions) : BasePage(pageResId = R.id.assignmentDetailsPage) { private val backButton by OnViewWithContentDescription(androidx.appcompat.R.string.abc_action_bar_up_description,false) private val toolbarTitle by OnViewWithText(R.string.assignment_details) @@ -132,7 +133,7 @@ class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { * @param assignment */ fun assertAssignmentDetails(assignment: Assignment) { - assertAssignmentDetails(assignment.name!!, assignment.published) + assertAssignmentDetails(assignmentNameTextView, assignment.name!!, assignment.published) } /** @@ -141,7 +142,7 @@ class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { * @param assignment */ fun assertAssignmentDetails(assignment: AssignmentApiModel) { - assertAssignmentDetails(assignment.name, assignment.published) + assertAssignmentDetails(assignmentNameTextView, assignment.name, assignment.published) } /** @@ -333,13 +334,13 @@ class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { } /** - * Assert assignment details + * Assert module item details * - * @param assignmentName + * @param moduleItemName * @param published */ - private fun assertAssignmentDetails(assignmentName: String, published: Boolean) { - assignmentNameTextView.assertHasText(assignmentName) + private fun assertAssignmentDetails(viewInteraction: ViewInteraction, moduleItemName: String, published: Boolean) { + viewInteraction.assertHasText(moduleItemName) assertPublishedStatus(published) } } 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 840968381c..89d6564c8f 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 @@ -37,6 +37,7 @@ 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.scrollTo import com.instructure.espresso.swipeDown import com.instructure.espresso.waitForCheck import com.instructure.teacher.R @@ -119,8 +120,7 @@ class CourseBrowserPage : BasePage() { * Opens the pages tab in the course browser. */ fun openPagesTab() { - scrollDownToCourseBrowser(scrollPosition = magicNumberForScroll) - waitForViewWithText(R.string.tab_pages).click() + waitForViewWithText(R.string.tab_pages).scrollTo().click() } /** 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 65f2600f8d..472b6af6ba 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 @@ -16,6 +16,7 @@ */ package com.instructure.teacher.ui.pages +import com.instructure.espresso.ModuleItemInteractions import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertHasText import com.instructure.espresso.assertNotDisplayed @@ -28,7 +29,7 @@ import com.instructure.espresso.swipeDown import com.instructure.teacher.R import com.instructure.teacher.ui.utils.TypeInRCETextEditor -class DiscussionsDetailsPage : BasePage() { +class DiscussionsDetailsPage(val moduleItemInteractions: ModuleItemInteractions) : BasePage() { /** * Asserts that the discussion has the specified [title]. 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 b68f2d69f9..6f89f22fd8 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,9 +9,12 @@ 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.dataseeding.model.PageApiModel import com.instructure.espresso.ActivityHelper +import com.instructure.espresso.ModuleItemInteractions import com.instructure.espresso.WaitForViewWithId import com.instructure.espresso.click +import com.instructure.espresso.extractInnerTextById import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.replaceText @@ -27,7 +30,7 @@ import org.hamcrest.Matchers.containsString * * @constructor Creates an instance of `EditPageDetailsPage`. */ -class EditPageDetailsPage : BasePage() { +class EditPageDetailsPage(val moduleItemInteractions: ModuleItemInteractions) : BasePage() { private val contentRceView by WaitForViewWithId(R.id.rce_webView) /** @@ -104,6 +107,16 @@ class EditPageDetailsPage : BasePage() { savePage() checkToastText(R.string.frontPageUnpublishedError, ActivityHelper.currentActivity()) } + + /** + * Assert that the page's body is equal to the expected + * + * @param page The page object to assert. + */ + fun assertPageDetails(page: PageApiModel) { + val innerText = extractInnerTextById(page.body, "header1") + runTextChecks(WebViewTextCheck(Locator.ID, "header1", innerText!!)) + } } data class WebViewTextCheck( 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 55acb833fc..d872a7ad6d 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 @@ -1,9 +1,11 @@ package com.instructure.teacher.ui.pages +import androidx.annotation.StringRes import androidx.test.espresso.matcher.ViewMatchers.hasSibling import androidx.test.espresso.matcher.ViewMatchers.withChild import com.instructure.espresso.RecyclerViewItemCountAssertion import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertHasContentDescription import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage @@ -12,7 +14,9 @@ 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.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.teacher.R @@ -34,12 +38,9 @@ class ModulesPage : BasePage() { 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() + fun assertModuleNotPublished(moduleTitle: String) { + onView(withId(R.id.unpublishedIcon) + hasSibling(withId(R.id.moduleName) + withText(moduleTitle))).assertDisplayed() + onView(withId(R.id.publishedIcon) + hasSibling(withId(R.id.moduleName) + withText(moduleTitle))).assertNotDisplayed() } /** @@ -50,6 +51,11 @@ class ModulesPage : BasePage() { onView(withId(R.id.publishedIcon)).assertDisplayed() } + fun assertModuleIsPublished(moduleTitle: String) { + onView(withId(R.id.unpublishedIcon) + hasSibling(withId(R.id.moduleName) + withText(moduleTitle))).assertNotDisplayed() + onView(withId(R.id.publishedIcon) + hasSibling(withId(R.id.moduleName) + withText(moduleTitle))).assertDisplayed() + } + /** * Asserts that the module with the specified title is displayed. * @@ -90,21 +96,20 @@ class ModulesPage : BasePage() { * @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() + onView(withAncestor(withChild(withText(moduleItemName))) + withId(R.id.moduleItemStatusIcon)).assertHasContentDescription( + R.string.a11y_published + ) } /** * 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() + fun assertModuleItemNotPublished(moduleItemName: String) { + onView(withAncestor(withChild(withText(moduleItemName))) + withId(R.id.moduleItemStatusIcon)).assertHasContentDescription( + R.string.a11y_unpublished + ) } /** @@ -121,7 +126,55 @@ class ModulesPage : BasePage() { * @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. + 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. + } + + fun assertToolbarMenuItems() { + onView(withText(R.string.publishAllModulesAndItems)).assertDisplayed() + onView(withText(R.string.publishModulesOnly)).assertDisplayed() + onView(withText(R.string.unpublishAllModulesAndItems)).assertDisplayed() + } + + fun clickItemOverflow(itemName: String) { + onView(withParent(withChild(withText(itemName))) + withId(R.id.overflow)).scrollTo().click() + } + + fun assertModuleMenuItems() { + onView(withText(R.string.publishModuleAndItems)).assertDisplayed() + onView(withText(R.string.publishModuleOnly)).assertDisplayed() + onView(withText(R.string.unpublishModuleAndItems)).assertDisplayed() + } + + fun assertOverflowItem(@StringRes title: Int) { + onView(withText(title)).assertDisplayed() + } + + fun assertFileEditDialogVisible() { + onView(withText(R.string.edit_permissions)).assertDisplayed() + } + + fun clickOnText(@StringRes title: Int) { + onView(withText(title)).click() + } + + fun assertSnackbarText(@StringRes snackbarText: Int) { + onView(withId(com.google.android.material.R.id.snackbar_text) + withText(snackbarText)).assertDisplayed() + } + + fun assertModuleItemHidden(moduleItemName: String) { + onView(withAncestor(withChild(withText(moduleItemName))) + withId(R.id.moduleItemStatusIcon)).assertHasContentDescription( + R.string.a11y_hidden + ) + } + + fun assertModuleItemScheduled(moduleItemName: String) { + onView(withAncestor(withChild(withText(moduleItemName))) + withId(R.id.moduleItemStatusIcon)).assertHasContentDescription( + R.string.a11y_scheduled + ) } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProgressPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProgressPage.kt new file mode 100644 index 0000000000..9a952b34aa --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProgressPage.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2024 - 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.teacher.ui.pages + +import androidx.annotation.StringRes +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.getStringFromResource + +@OptIn(ExperimentalTestApi::class) +class ProgressPage(private val composeTestRule: ComposeTestRule) : BasePage() { + + fun clickDone() { + composeTestRule.waitForIdle() + composeTestRule.waitUntilExactlyOneExists(hasText("Done"), 10000) + composeTestRule.onNodeWithText("Done").performClick() + } + + fun assertProgressPageTitle(@StringRes title: Int) { + composeTestRule.waitUntilExactlyOneExists(hasText(getStringFromResource(title)), 10000) + composeTestRule.onNodeWithText(getStringFromResource(title)).assertIsDisplayed() + } + + fun assertProgressPageNote(@StringRes note: Int) { + composeTestRule.waitUntilExactlyOneExists(hasText(getStringFromResource(note)), 10000) + composeTestRule.onNodeWithText(getStringFromResource(note)).assertIsDisplayed() + } +} \ No newline at end of file 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 19d6249ab0..7f683cdb4c 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 @@ -16,8 +16,9 @@ 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.dataseeding.model.QuizApiModel +import com.instructure.espresso.ModuleItemInteractions import com.instructure.espresso.OnViewWithContentDescription import com.instructure.espresso.OnViewWithId import com.instructure.espresso.OnViewWithText @@ -34,6 +35,7 @@ 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.page.withId import com.instructure.espresso.swipeDown import com.instructure.teacher.R @@ -46,7 +48,7 @@ import com.instructure.teacher.R * 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) { +class QuizDetailsPage(val moduleItemInteractions: ModuleItemInteractions) : BasePage(pageResId = R.id.quizDetailsPage) { private val backButton by OnViewWithContentDescription(R.string.abc_action_bar_up_description,false) private val toolbarTitle by OnViewWithText(R.string.quiz_details) @@ -113,8 +115,27 @@ class QuizDetailsPage : BasePage(pageResId = R.id.quizDetailsPage) { * @param quiz The Quiz object representing the quiz details. */ fun assertQuizDetails(quiz: Quiz) { - quizTitleTextView.assertHasText(quiz.title!!) - if (quiz.published) { + assertQuizDetails(quiz.title!!, quiz.published) + } + + /** + * Asserts the quiz details such as title and publish status. + * + * @param quiz The Quiz object representing the quiz details. + */ + fun assertQuizDetails(quiz: QuizApiModel) { + assertQuizDetails(quiz.title, quiz.published) + } + + /** + * Assert quiz details + * Private method used for overloading. + * @param quizTitle The quiz's title + * @param published The quiz's published status + */ + private fun assertQuizDetails(quizTitle: String, published: Boolean) { + quizTitleTextView.assertHasText(quizTitle) + if (published) { publishStatusTextView.assertHasText(R.string.published) } else { publishStatusTextView.assertHasText(R.string.not_published) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/UpdateFilePermissionsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/UpdateFilePermissionsPage.kt new file mode 100644 index 0000000000..798d03d596 --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/UpdateFilePermissionsPage.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2024 - 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.teacher.ui.pages + +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.assertChecked +import com.instructure.espresso.assertDisabled +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertEnabled +import com.instructure.espresso.assertHasText +import com.instructure.espresso.assertNotDisplayed +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onViewWithText +import com.instructure.espresso.page.waitForViewWithId +import com.instructure.espresso.scrollTo +import com.instructure.espresso.swipeUp +import com.instructure.teacher.R +import java.text.SimpleDateFormat +import java.util.Date + +class UpdateFilePermissionsPage : BasePage() { + + private val saveButton by OnViewWithId(R.id.updateButton) + private val publishRadioButton by OnViewWithId(R.id.publish) + private val unpublishRadioButton by OnViewWithId(R.id.unpublish) + private val hideRadioButton by OnViewWithId(R.id.hide) + private val scheduleRadioButton by OnViewWithId(R.id.schedule) + private val inheritRadioButton by OnViewWithId(R.id.visibilityInherit) + private val contextRadioButton by OnViewWithId(R.id.visibilityContext) + private val institutionRadioButton by OnViewWithId(R.id.visibilityInstitution) + private val publicRadioButton by OnViewWithId(R.id.visibilityPublic) + private val scheduleLayout by OnViewWithId(R.id.scheduleLayout) + private val availableFromDate by OnViewWithId(R.id.availableFromDate) + private val availableFromTime by OnViewWithId(R.id.availableFromTime) + private val availableUntilDate by OnViewWithId(R.id.availableUntilDate) + private val availableUntilTime by OnViewWithId(R.id.availableUntilTime) + + fun assertFilePublished() { + publishRadioButton.assertChecked() + } + + fun assertFileUnpublished() { + unpublishRadioButton.assertChecked() + } + + fun assertFileHidden() { + hideRadioButton.assertChecked() + } + + fun assertFileScheduled() { + scheduleRadioButton.assertChecked() + } + + fun assertFileVisibilityInherit() { + inheritRadioButton.assertChecked() + } + + fun assertFileVisibilityContext() { + contextRadioButton.assertChecked() + } + + fun assertFileVisibilityInstitution() { + institutionRadioButton.assertChecked() + } + + fun assertFileVisibilityPublic() { + publicRadioButton.assertChecked() + } + + fun clickSaveButton() { + saveButton.click() + } + + fun clickPublishRadioButton() { + waitForViewWithId(R.id.publish).click() + } + + fun clickUnpublishRadioButton() { + waitForViewWithId(R.id.unpublish).click() + } + + fun clickHideRadioButton() { + waitForViewWithId(R.id.hide).click() + } + + fun assertScheduleLayoutDisplayed() { + scheduleLayout.assertDisplayed() + } + + fun assertScheduleLayoutNotDisplayed() { + scheduleLayout.assertNotDisplayed() + } + + fun assertUnlockDate(unlockDate: Date) { + val dateString = SimpleDateFormat("MMM d, YYYY").format(unlockDate) + val timeString = SimpleDateFormat("h:mm a").format(unlockDate) + + waitForViewWithId(R.id.availableFromDate).scrollTo().assertDisplayed() + availableFromDate.assertHasText(dateString) + availableFromTime.assertHasText(timeString) + } + + fun assertLockDate(lockDate: Date) { + val dateString = SimpleDateFormat("MMM d, YYYY").format(lockDate) + val timeString = SimpleDateFormat("h:mm a").format(lockDate) + + waitForViewWithId(R.id.availableUntilDate).scrollTo().assertDisplayed() + availableUntilDate.assertHasText(dateString) + availableUntilTime.assertHasText(timeString) + } + + fun assertVisibilityDisabled() { + inheritRadioButton.assertDisabled() + contextRadioButton.assertDisabled() + institutionRadioButton.assertDisabled() + publicRadioButton.assertDisabled() + } + + fun assertVisibilityEnabled() { + inheritRadioButton.assertEnabled() + contextRadioButton.assertEnabled() + institutionRadioButton.assertEnabled() + publicRadioButton.assertEnabled() + } + + fun swipeUpBottomSheet() { + onViewWithText(R.string.edit_permissions).swipeUp() + Thread.sleep(1000) + } +} \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt index 960d9b2744..31bf453bbf 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt @@ -16,10 +16,12 @@ package com.instructure.teacher.ui.renderTests import android.graphics.Color -import android.os.Build import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isEnabled import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.utils.toApiString import com.instructure.espresso.assertCompletelyDisplayed import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertHasText @@ -37,6 +39,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.hamcrest.CoreMatchers.not import org.junit.Before import org.junit.Test +import java.util.Date @HiltAndroidTest class ModuleListRenderTest : TeacherRenderTest() { @@ -51,17 +54,20 @@ class ModuleListRenderTest : TeacherRenderTest() { id = 1L, name = "Module 1", isPublished = true, + isLoading = false, moduleItems = emptyList() ) moduleItemTemplate = ModuleListItemData.ModuleItemData( id = 2L, title = "Assignment Module Item", subtitle = "Due Tomorrow", + subtitle2 = "10 pts", iconResId = R.drawable.ic_assignment, isPublished = true, indent = 0, tintColor = Color.BLUE, - enabled = true + enabled = true, + type = ModuleItem.Type.Assignment ) } @@ -86,9 +92,9 @@ class ModuleListRenderTest : TeacherRenderTest() { fun displaysInlineError() { val state = ModuleListViewState( items = listOf( - ModuleListItemData.ModuleData(1, "Module 1", true, emptyList()), - ModuleListItemData.ModuleData(2, "Module 2", true, emptyList()), - ModuleListItemData.ModuleData(3, "Module 3", true, emptyList()), + ModuleListItemData.ModuleData(1, "Module 1", true, emptyList(), false), + ModuleListItemData.ModuleData(2, "Module 2", true, emptyList(), false), + ModuleListItemData.ModuleData(3, "Module 3", true, emptyList(), false), ModuleListItemData.InlineError(Color.BLUE) ) ) @@ -107,7 +113,7 @@ class ModuleListRenderTest : TeacherRenderTest() { @Test fun displaysEmptyModule() { - val module = ModuleListItemData.ModuleData(1, "Module 1", true, emptyList()) + val module = ModuleListItemData.ModuleData(1, "Module 1", true, emptyList(), false) val state = ModuleListViewState( items = listOf(module) ) @@ -128,7 +134,7 @@ class ModuleListRenderTest : TeacherRenderTest() { fun displaysInlineLoadingView() { val state = ModuleListViewState( items = listOf( - ModuleListItemData.ModuleData(1, "Module 1", true, emptyList()), + ModuleListItemData.ModuleData(1, "Module 1", true, emptyList(), false), ModuleListItemData.Loading ) ) @@ -156,8 +162,7 @@ class ModuleListRenderTest : TeacherRenderTest() { items = listOf(moduleItem) ) loadPageWithViewState(state) - page.moduleItemPublishedIcon.assertDisplayed() - page.moduleItemUnpublishedIcon.assertNotDisplayed() + page.assertStatusIconContentDescription(R.string.a11y_published) } @Test @@ -169,21 +174,7 @@ class ModuleListRenderTest : TeacherRenderTest() { items = listOf(moduleItem) ) loadPageWithViewState(state) - page.moduleItemUnpublishedIcon.assertDisplayed() - page.moduleItemPublishedIcon.assertNotDisplayed() - } - - @Test - fun doesNotDisplayModuleItemPublishStatusIcon() { - val moduleItem = moduleItemTemplate.copy( - isPublished = null - ) - val state = ModuleListViewState( - items = listOf(moduleItem) - ) - loadPageWithViewState(state) - page.moduleItemUnpublishedIcon.assertNotDisplayed() - page.moduleItemPublishedIcon.assertNotDisplayed() + page.assertStatusIconContentDescription(R.string.a11y_unpublished) } @Test @@ -215,13 +206,16 @@ class ModuleListRenderTest : TeacherRenderTest() { id = idx + 2L, title = "Module Item ${idx + 1}", subtitle = null, + subtitle2 = null, iconResId = R.drawable.ic_assignment, isPublished = false, + isLoading = false, indent = 0, tintColor = Color.BLUE, - enabled = true + enabled = true, + type = ModuleItem.Type.Assignment ) - } + }, false ) ) ) @@ -308,13 +302,16 @@ class ModuleListRenderTest : TeacherRenderTest() { id = idx + 2L, title = "Module Item ${idx + 1}", subtitle = null, + subtitle2 = null, iconResId = R.drawable.ic_assignment, isPublished = false, + isLoading = false, indent = 0, tintColor = Color.BLUE, - enabled = true + enabled = true, + type = ModuleItem.Type.Assignment ) - } + }, false ) ), collapsedModuleIds = setOf(1L) @@ -327,7 +324,16 @@ class ModuleListRenderTest : TeacherRenderTest() { fun scrollsToTargetItem() { val itemCount = 50 val targetItem = ModuleListItemData.ModuleItemData( - 1234L, "This is the target item", null, R.drawable.ic_attachment, false, 0, Color.BLUE, true + 1234L, + "This is the target item", + null, + null, + R.drawable.ic_attachment, + false, + 0, + Color.BLUE, + true, + type = ModuleItem.Type.Assignment ) val state = ModuleListViewState( items = listOf( @@ -339,10 +345,11 @@ class ModuleListRenderTest : TeacherRenderTest() { } else { moduleItemTemplate.copy( id = idx + 2L, - title = "Module Item ${idx + 1}" + title = "Module Item ${idx + 1}", + isLoading = false ) } - } + }, false ) ) ) @@ -380,6 +387,42 @@ class ModuleListRenderTest : TeacherRenderTest() { page.moduleItemRoot.check(matches(not(isEnabled()))) } + @Test + fun displaysFileModuleItemHiddenIcon() { + val item = moduleItemTemplate.copy( + iconResId = R.drawable.ic_attachment, + type = ModuleItem.Type.File, + contentDetails = ModuleContentDetails( + hidden = true + ) + ) + val state = ModuleListViewState( + items = listOf(item) + ) + loadPageWithViewState(state) + page.moduleItemIcon.assertDisplayed() + page.assertStatusIconContentDescription(R.string.a11y_hidden) + } + + @Test + fun displaysFileModuleItemScheduledIcon() { + val item = moduleItemTemplate.copy( + iconResId = R.drawable.ic_attachment, + type = ModuleItem.Type.File, + contentDetails = ModuleContentDetails( + hidden = false, + locked = true, + unlockAt = Date().toApiString() + ) + ) + val state = ModuleListViewState( + items = listOf(item) + ) + loadPageWithViewState(state) + page.moduleItemIcon.assertDisplayed() + page.assertStatusIconContentDescription(R.string.a11y_scheduled) + } + private fun loadPageWithViewState( state: ModuleListViewState, course: Course = Course(name = "Test Course") diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt index ca1832ec17..7fa38d027c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt @@ -15,10 +15,12 @@ */ package com.instructure.teacher.ui.renderTests.pages +import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import com.instructure.espresso.OnViewWithId import com.instructure.espresso.RecyclerViewItemCountAssertion import com.instructure.espresso.assertDisplayed @@ -51,6 +53,7 @@ class ModuleListRenderPage : BasePage(R.id.moduleList) { val moduleItemTitle by OnViewWithId(R.id.moduleItemTitle) val moduleItemIndent by OnViewWithId(R.id.moduleItemIndent) val moduleItemSubtitle by OnViewWithId(R.id.moduleItemSubtitle) + val moduleItemStatusIcon by OnViewWithId(R.id.moduleItemStatusIcon) val moduleItemPublishedIcon by OnViewWithId(R.id.moduleItemPublishedIcon) val moduleItemUnpublishedIcon by OnViewWithId(R.id.moduleItemUnpublishedIcon) val moduleItemLoadingView by OnViewWithId(R.id.moduleItemLoadingView) @@ -76,4 +79,8 @@ class ModuleListRenderPage : BasePage(R.id.moduleList) { fun assertHasItemIndent(indent: Int) { moduleItemIndent.check(matches(ViewSizeMatcher.hasWidth(indent))) } + + fun assertStatusIconContentDescription(@StringRes contentDescription: Int) { + moduleItemStatusIcon.check(matches(withContentDescription(contentDescription))) + } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt new file mode 100644 index 0000000000..10679bf125 --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 - 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.teacher.ui.utils + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.instructure.teacher.activities.LoginActivity +import com.instructure.teacher.ui.pages.ProgressPage +import org.junit.Rule + +abstract class TeacherComposeTest : TeacherTest() { + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + val progressPage = ProgressPage(composeTestRule) +} \ No newline at end of file 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 dd1f97d460..1c15a9bc52 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 @@ -23,8 +23,11 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice import com.instructure.canvas.espresso.CanvasTest import com.instructure.espresso.InstructureActivityTestRule +import com.instructure.espresso.ModuleItemInteractions import com.instructure.espresso.Searchable import com.instructure.teacher.BuildConfig import com.instructure.teacher.R @@ -84,6 +87,7 @@ import com.instructure.teacher.ui.pages.SpeedGraderQuizSubmissionPage import com.instructure.teacher.ui.pages.StudentContextPage import com.instructure.teacher.ui.pages.SyllabusPage import com.instructure.teacher.ui.pages.TodoPage +import com.instructure.teacher.ui.pages.UpdateFilePermissionsPage import com.instructure.teacher.ui.pages.WebViewLoginPage import dagger.hilt.android.testing.HiltAndroidRule import instructure.rceditor.RCETextEditor @@ -99,6 +103,8 @@ abstract class TeacherTest : CanvasTest() { override val isTesting = BuildConfig.IS_TESTING + val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + @Inject lateinit var workerFactory: HiltWorkerFactory @@ -125,7 +131,7 @@ abstract class TeacherTest : CanvasTest() { val addMessagePage = AddMessagePage() val announcementsListPage = AnnouncementsListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val assigneeListPage = AssigneeListPage() - val assignmentDetailsPage = AssignmentDetailsPage() + val assignmentDetailsPage = AssignmentDetailsPage(ModuleItemInteractions(R.id.moduleName, R.id.next, R.id.previous)) val assignmentDueDatesPage = AssignmentDueDatesPage() val assignmentListPage = AssignmentListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val assignmentSubmissionListPage = AssignmentSubmissionListPage() @@ -145,12 +151,12 @@ abstract class TeacherTest : CanvasTest() { val remoteConfigSettingsPage = RemoteConfigSettingsPage() val profileSettingsPage = ProfileSettingsPage() val editProfileSettingsPage = EditProfileSettingsPage() - val discussionsDetailsPage = DiscussionsDetailsPage() + val discussionsDetailsPage = DiscussionsDetailsPage(ModuleItemInteractions(R.id.moduleName, R.id.next, R.id.previous)) val discussionsListPage = DiscussionsListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val editAnnouncementDetailsPage = EditAnnouncementDetailsPage() val editAssignmentDetailsPage = EditAssignmentDetailsPage() val editDiscussionsDetailsPage = EditDiscussionsDetailsPage() - val editPageDetailsPage = EditPageDetailsPage() + val editPageDetailsPage = EditPageDetailsPage(ModuleItemInteractions(R.id.moduleName, R.id.next, R.id.previous)) val editQuizDetailsPage = EditQuizDetailsPage() val editSyllabusPage = EditSyllabusPage() val inboxMessagePage = InboxMessagePage() @@ -158,12 +164,12 @@ abstract class TeacherTest : CanvasTest() { val loginFindSchoolPage = LoginFindSchoolPage() val loginLandingPage = LoginLandingPage() val loginSignInPage = LoginSignInPage() - val modulesPage = ModulesPage() + val moduleListPage = ModulesPage() val navDrawerPage = NavDrawerPage() val notATeacherPage = NotATeacherPage() 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 quizDetailsPage = QuizDetailsPage(ModuleItemInteractions(R.id.moduleName, R.id.next, R.id.previous)) val quizListPage = QuizListPage(Searchable(R.id.search, R.id.search_src_text, R.id.clearButton, R.id.backButton)) val quizSubmissionListPage = QuizSubmissionListPage() val speedGraderCommentsPage = SpeedGraderCommentsPage() @@ -177,6 +183,7 @@ abstract class TeacherTest : CanvasTest() { val todoPage = TodoPage() val webViewLoginPage = WebViewLoginPage() val fileListPage = FileListPage(Searchable(R.id.search, R.id.queryInput, R.id.clearButton, R.id.backButton)) + val updateFilePermissionsPage = UpdateFilePermissionsPage() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTestExtensions.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTestExtensions.kt index 83aa31f580..37ecc53877 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTestExtensions.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTestExtensions.kt @@ -29,8 +29,29 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.platform.app.InstrumentationRegistry import com.instructure.canvas.espresso.waitForMatcherWithSleeps import com.instructure.canvasapi2.models.User -import com.instructure.dataseeding.api.* -import com.instructure.dataseeding.model.* +import com.instructure.dataseeding.api.AssignmentsApi +import com.instructure.dataseeding.api.ConversationsApi +import com.instructure.dataseeding.api.CoursesApi +import com.instructure.dataseeding.api.EnrollmentsApi +import com.instructure.dataseeding.api.FileUploadsApi +import com.instructure.dataseeding.api.PagesApi +import com.instructure.dataseeding.api.QuizzesApi +import com.instructure.dataseeding.api.SeedApi +import com.instructure.dataseeding.api.SubmissionsApi +import com.instructure.dataseeding.api.UserApi +import com.instructure.dataseeding.model.AssignmentApiModel +import com.instructure.dataseeding.model.AttachmentApiModel +import com.instructure.dataseeding.model.CanvasUserApiModel +import com.instructure.dataseeding.model.ConversationListApiModel +import com.instructure.dataseeding.model.CourseApiModel +import com.instructure.dataseeding.model.EnrollmentTypes +import com.instructure.dataseeding.model.FileType +import com.instructure.dataseeding.model.FileUploadType +import com.instructure.dataseeding.model.PageApiModel +import com.instructure.dataseeding.model.QuizListApiModel +import com.instructure.dataseeding.model.QuizSubmissionApiModel +import com.instructure.dataseeding.model.SubmissionApiModel +import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.CanvasNetworkAdapter import com.instructure.dataseeding.util.DataSeedingException import com.instructure.dataseeding.util.Randomizer @@ -40,7 +61,11 @@ import com.instructure.teacher.activities.LoginActivity import com.instructure.teacher.router.RouteMatcher import org.hamcrest.CoreMatchers.allOf import org.hamcrest.Matchers.anyOf -import java.io.* +import java.io.BufferedInputStream +import java.io.DataInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileWriter fun TeacherTest.enterDomain(enrollmentType: String = EnrollmentTypes.TEACHER_ENROLLMENT): CanvasUserApiModel { @@ -251,16 +276,7 @@ fun TeacherTest.seedAssignmentSubmission( it.attachmentsList.addAll(fileAttachments) } - // Seed the submissions - val submissionRequest = SubmissionsApi.SubmissionSeedRequest( - assignmentId = assignmentId, - courseId = courseId, - studentToken = studentToken, - submissionSeedsList = submissionSeeds, - commentSeedsList = commentSeeds - ) - - return SubmissionsApi.seedAssignmentSubmission(submissionRequest) + return SubmissionsApi.seedAssignmentSubmission(courseId, studentToken, assignmentId, commentSeeds, submissionSeeds) } fun TeacherTest.uploadTextFile(courseId: Long, assignmentId: Long, token: String, fileUploadType: FileUploadType): AttachmentApiModel { diff --git a/apps/teacher/src/main/AndroidManifest.xml b/apps/teacher/src/main/AndroidManifest.xml index 8ce6e5605d..69124c6abb 100644 --- a/apps/teacher/src/main/AndroidManifest.xml +++ b/apps/teacher/src/main/AndroidManifest.xml @@ -97,7 +97,7 @@ android:noHistory="true" android:theme="@style/LoginFlowTheme.Splash_Teacher" android:exported="true"> - + @@ -107,7 +107,7 @@ android:host="*.instructure.com" android:scheme="https" /> - + @@ -117,7 +117,7 @@ android:host="*.instructure.com" android:scheme="http" /> - + @@ -127,7 +127,7 @@ android:host="*.canvas.net" android:scheme="https" /> - + 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 a00a2671b8..0e550b3576 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 @@ -167,7 +167,7 @@ class InitActivity : BasePresenterActivity On Create") val masqueradingUserId: Long = intent.getLongExtra(Const.QR_CODE_MASQUERADE_ID, 0L) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt b/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt index 68c8415fe0..d9e092bdb9 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt @@ -21,11 +21,13 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.Handler -import androidx.fragment.app.FragmentActivity import android.view.Window import android.widget.Toast +import androidx.fragment.app.FragmentActivity +import com.instructure.canvasapi2.managers.OAuthManager import com.instructure.canvasapi2.models.AccountDomain import com.instructure.canvasapi2.utils.* +import com.instructure.canvasapi2.utils.weave.apiAsync import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.interactions.router.Route @@ -34,9 +36,11 @@ import com.instructure.interactions.router.RouterParams import com.instructure.loginapi.login.tasks.LogoutTask import com.instructure.loginapi.login.util.QRLogin import com.instructure.loginapi.login.util.QRLogin.verifySSOLoginUri +import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.Utils import com.instructure.teacher.R +import com.instructure.teacher.databinding.ActivityRouteValidatorBinding import com.instructure.teacher.fragments.FileListFragment import com.instructure.teacher.router.RouteMatcher import com.instructure.teacher.services.FileDownloadService @@ -45,12 +49,14 @@ import kotlinx.coroutines.Job class RouteValidatorActivity : FragmentActivity() { + private val binding by viewBinding(ActivityRouteValidatorBinding::inflate) + private var routeValidatorJob: Job? = null public override fun onCreate(savedInstanceState: Bundle?) { requestWindowFeature(Window.FEATURE_NO_TITLE) super.onCreate(savedInstanceState) - setContentView(R.layout.activity_route_validator) + setContentView(binding.root) val data: Uri? = intent.data val url: String? = data?.toString() @@ -83,6 +89,13 @@ class RouteValidatorActivity : FragmentActivity() { val tokenResponse = QRLogin.performSSOLogin(data, this@RouteValidatorActivity, true) + val authResult = apiAsync { OAuthManager.getAuthenticatedSession(ApiPrefs.fullDomain, it) }.await() + if (authResult.isSuccess) { + authResult.dataOrNull?.sessionUrl?.let { + binding.dummyWebView.loadUrl(it) + } + } + // If we have a real user, this is a QR code from a masquerading web user val intent = if (tokenResponse.realUser != null && tokenResponse.user != null) { // We need to set the masquerade request to the user (masqueradee), the real user it the admin user currently masquerading diff --git a/apps/teacher/src/main/java/com/instructure/teacher/di/EventBusModule.kt b/apps/teacher/src/main/java/com/instructure/teacher/di/EventBusModule.kt new file mode 100644 index 0000000000..f021eb0648 --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/di/EventBusModule.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 - 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.teacher.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import org.greenrobot.eventbus.EventBus + +@Module +@InstallIn(ViewModelComponent::class) +class EventBusModule { + + @Provides + fun provideEventBus(): EventBus { + return EventBus.getDefault() + } +} + diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherLoginNavigation.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherLoginNavigation.kt index 5bd968dd79..5a0b7c3a98 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherLoginNavigation.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherLoginNavigation.kt @@ -17,6 +17,7 @@ package com.instructure.teacher.features.login import android.content.Intent +import android.webkit.CookieManager import androidx.fragment.app.FragmentActivity import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.loginapi.login.LoginNavigation @@ -35,6 +36,8 @@ class TeacherLoginNavigation(private val activity: FragmentActivity) : LoginNavi override fun initMainActivityIntent(): Intent { PushNotificationRegistrationWorker.scheduleJob(activity, ApiPrefs.isMasquerading) + CookieManager.getInstance().flush() + return SplashActivity.createIntent(activity, activity.intent?.extras) } } \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt index 1041a022dc..5b8591919c 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt @@ -17,10 +17,14 @@ package com.instructure.teacher.features.modules.list import com.instructure.canvasapi2.CanvasRestAdapter +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.apis.ProgressAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.managers.ModuleManager import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.canvasapi2.models.Progress import com.instructure.canvasapi2.utils.APIHelper import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.Failure @@ -29,27 +33,68 @@ import com.instructure.canvasapi2.utils.isValid import com.instructure.canvasapi2.utils.tryOrNull import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.awaitApiResponse +import com.instructure.pandautils.features.progress.ProgressPreferences +import com.instructure.pandautils.room.appdatabase.daos.ModuleBulkProgressDao +import com.instructure.pandautils.room.appdatabase.entities.ModuleBulkProgressEntity +import com.instructure.pandautils.utils.poll +import com.instructure.pandautils.utils.retry +import com.instructure.teacher.R import com.instructure.teacher.features.modules.list.ui.ModuleListView import com.instructure.teacher.mobius.common.ui.EffectHandler import kotlinx.coroutines.launch import retrofit2.Response -class ModuleListEffectHandler : EffectHandler() { +class ModuleListEffectHandler( + private val moduleApi: ModuleAPI.ModuleInterface, + private val progressApi: ProgressAPI.ProgressInterface, + private val progressPreferences: ProgressPreferences, + private val moduleBulkProgressDao: ModuleBulkProgressDao +) : EffectHandler() { override fun accept(effect: ModuleListEffect) { when (effect) { is ModuleListEffect.ShowModuleItemDetailView -> { view?.routeToModuleItem(effect.moduleItem, effect.canvasContext) } + is ModuleListEffect.LoadNextPage -> loadNextPage( effect.canvasContext, effect.pageData, effect.scrollToItemId ) + is ModuleListEffect.ScrollToItem -> view?.scrollToItem(effect.moduleItemId) is ModuleListEffect.MarkModuleExpanded -> { CollapsedModulesStore.markModuleCollapsed(effect.canvasContext, effect.moduleId, !effect.isExpanded) } + is ModuleListEffect.UpdateModuleItems -> updateModuleItems(effect.canvasContext, effect.items) + is ModuleListEffect.BulkUpdateModules -> bulkUpdateModules( + effect.canvasContext, + effect.moduleIds, + effect.affectedIds, + effect.action, + effect.skipContentTags, + allModules = effect.allModules + ) + + is ModuleListEffect.UpdateModuleItem -> updateModuleItem( + effect.canvasContext, + effect.moduleId, + effect.itemId, + effect.published + ) + + is ModuleListEffect.ShowSnackbar -> { + view?.showSnackbar(effect.message) + } + + is ModuleListEffect.UpdateFileModuleItem -> { + view?.showUpdateFileDialog(effect.fileId, effect.contentDetails) + } + + is ModuleListEffect.BulkUpdateStarted -> { + handleBulkUpdate(effect.progressId, effect.allModules, effect.skipContentTags, effect.action) + } }.exhaustive } @@ -130,9 +175,11 @@ class ModuleListEffectHandler : EffectHandler awaitApiResponse { ModuleManager.getFirstPageModulesWithItems(canvasContext, it, pageData.forceNetwork) } + pageData.nextPageUrl.isValid() -> awaitApiResponse { ModuleManager.getNextPageModuleObjects(pageData.nextPageUrl, it, pageData.forceNetwork) } + else -> throw IllegalStateException("Unable to fetch page data; invalid nextPageUrl") } @@ -154,4 +201,142 @@ class ModuleListEffectHandler : EffectHandler, + affectedIds: List, + action: BulkModuleUpdateAction, + skipContentTags: Boolean, + async: Boolean = true, + allModules: Boolean + ) { + launch { + val restParams = RestParams( + canvasContext = canvasContext, + isForceReadFromNetwork = true + ) + val progress = moduleApi.bulkUpdateModules( + canvasContext.type.apiString, + canvasContext.id, + moduleIds, + action.event, + skipContentTags, + async, + restParams + ).dataOrNull?.progress + + val bulkUpdateProgress = progress?.progress + if (bulkUpdateProgress == null) { + consumer.accept(ModuleListEvent.BulkUpdateFailed(skipContentTags)) + } else { + moduleBulkProgressDao.insert( + ModuleBulkProgressEntity( + courseId = canvasContext.id, + progressId = bulkUpdateProgress.id, + action = action.toString(), + skipContentTags = skipContentTags, + allModules = allModules, + affectedIds = affectedIds + ) + ) + if (allModules || !skipContentTags) { + showProgressScreen(bulkUpdateProgress.id, skipContentTags, action, allModules) + } + consumer.accept( + ModuleListEvent.BulkUpdateStarted( + canvasContext, + bulkUpdateProgress.id, + allModules, + skipContentTags, + affectedIds, + action + ) + ) + } + } + } + + private fun handleBulkUpdate( + progressId: Long, + allModules: Boolean, + skipContentTags: Boolean, + action: BulkModuleUpdateAction + ) { + launch { + val success = trackUpdateProgress(progressId) + moduleBulkProgressDao.deleteById(progressId) + + if (success) { + consumer.accept(ModuleListEvent.BulkUpdateSuccess(skipContentTags, action, allModules)) + } else { + if (progressPreferences.cancelledProgressIds.contains(progressId)) { + consumer.accept(ModuleListEvent.BulkUpdateCancelled) + progressPreferences.cancelledProgressIds = progressPreferences.cancelledProgressIds - progressId + } else { + consumer.accept(ModuleListEvent.BulkUpdateFailed(skipContentTags)) + } + } + } + } + + private suspend fun trackUpdateProgress(progressId: Long): Boolean { + val params = RestParams(isForceReadFromNetwork = true) + + val result = poll(500, maxAttempts = -1, + validate = { + it.hasRun + }, + block = { + var newProgress: Progress? = null + retry(initialDelay = 500) { + newProgress = progressApi.getProgress(progressId.toString(), params).dataOrThrow + } + newProgress + }) + + return result?.hasRun == true && result.isCompleted + } + + private fun updateModuleItem(canvasContext: CanvasContext, moduleId: Long, itemId: Long, published: Boolean) { + launch { + val restParams = RestParams( + canvasContext = canvasContext, + isForceReadFromNetwork = true + ) + val moduleItem = moduleApi.publishModuleItem( + canvasContext.type.apiString, + canvasContext.id, + moduleId, + itemId, + published, + restParams + ).dataOrNull + + moduleItem?.let { + consumer.accept(ModuleListEvent.ModuleItemUpdateSuccess(it, published)) + } ?: consumer.accept(ModuleListEvent.ModuleItemUpdateFailed(itemId)) + } + } + + private fun showProgressScreen( + progressId: Long, + skipContentTags: Boolean, + action: BulkModuleUpdateAction, + allModules: Boolean + ) { + val title = when { + allModules && skipContentTags -> R.string.allModules + allModules && !skipContentTags -> R.string.allModulesAndItems + !allModules && !skipContentTags -> R.string.selectedModulesAndItems + else -> R.string.selectedModules + } + + val progressTitle = when (action) { + BulkModuleUpdateAction.PUBLISH -> R.string.publishing + BulkModuleUpdateAction.UNPUBLISH -> R.string.unpublishing + } + + view?.showProgressDialog(progressId, title, progressTitle, R.string.moduleBulkUpdateNote) + } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt index 221d3ede60..7584ce52a6 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt @@ -16,7 +16,9 @@ */ package com.instructure.teacher.features.modules.list +import androidx.annotation.StringRes import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.utils.DataResult @@ -32,6 +34,33 @@ sealed class ModuleListEvent { data class ItemRefreshRequested(val type: String, val predicate: (item: ModuleItem) -> Boolean) : ModuleListEvent() data class ReplaceModuleItems(val items: List) : ModuleListEvent() data class RemoveModuleItems(val type: String, val predicate: (item: ModuleItem) -> Boolean) : ModuleListEvent() + data class BulkUpdateModule(val moduleId: Long, val action: BulkModuleUpdateAction, val skipContentTags: Boolean) : + ModuleListEvent() + + data class BulkUpdateAllModules(val action: BulkModuleUpdateAction, val skipContentTags: Boolean) : + ModuleListEvent() + + data class UpdateModuleItem(val itemId: Long, val isPublished: Boolean) : ModuleListEvent() + data class ModuleItemUpdateSuccess(val item: ModuleItem, val published: Boolean) : ModuleListEvent() + data class ModuleItemUpdateFailed(val itemId: Long) : ModuleListEvent() + data class BulkUpdateSuccess( + val skipContentTags: Boolean, + val action: BulkModuleUpdateAction, + val allModules: Boolean + ) : ModuleListEvent() + + data class BulkUpdateFailed(val skipContentTags: Boolean) : ModuleListEvent() + data class BulkUpdateStarted( + val canvasContext: CanvasContext, + val progressId: Long, + val allModules: Boolean, + val skipContentTags: Boolean, + val affectedIds: List, + val action: BulkModuleUpdateAction + ) : ModuleListEvent() + + data class UpdateFileModuleItem(val fileId: Long, val contentDetails: ModuleContentDetails) : ModuleListEvent() + object BulkUpdateCancelled : ModuleListEvent() } sealed class ModuleListEffect { @@ -39,18 +68,51 @@ sealed class ModuleListEffect { val moduleItem: ModuleItem, val canvasContext: CanvasContext ) : ModuleListEffect() + data class LoadNextPage( val canvasContext: CanvasContext, val pageData: ModuleListPageData, val scrollToItemId: Long? ) : ModuleListEffect() + data class ScrollToItem(val moduleItemId: Long) : ModuleListEffect() data class MarkModuleExpanded( val canvasContext: CanvasContext, val moduleId: Long, val isExpanded: Boolean ) : ModuleListEffect() + data class UpdateModuleItems(val canvasContext: CanvasContext, val items: List) : ModuleListEffect() + + data class BulkUpdateModules( + val canvasContext: CanvasContext, + val moduleIds: List, + val affectedIds: List, + val action: BulkModuleUpdateAction, + val skipContentTags: Boolean, + val allModules: Boolean + ) : ModuleListEffect() + + data class UpdateModuleItem( + val canvasContext: CanvasContext, + val moduleId: Long, + val itemId: Long, + val published: Boolean + ) : ModuleListEffect() + + data class ShowSnackbar(@StringRes val message: Int) : ModuleListEffect() + + data class UpdateFileModuleItem( + val fileId: Long, + val contentDetails: ModuleContentDetails + ) : ModuleListEffect() + + data class BulkUpdateStarted( + val progressId: Long, + val allModules: Boolean, + val skipContentTags: Boolean, + val action: BulkModuleUpdateAction + ) : ModuleListEffect() } data class ModuleListModel( @@ -70,3 +132,8 @@ data class ModuleListPageData( val isFirstPage get() = lastPageResult == null val hasMorePages get() = isFirstPage || nextPageUrl.isValid() } + +enum class BulkModuleUpdateAction(val event: String) { + PUBLISH("publish"), + UNPUBLISH("unpublish") +} diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt index 9526c732d4..369fa69bcc 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt @@ -27,6 +27,7 @@ import com.instructure.teacher.R import com.instructure.teacher.features.modules.list.ui.ModuleListItemData import com.instructure.teacher.features.modules.list.ui.ModuleListViewState import com.instructure.teacher.mobius.common.ui.Presenter +import kotlin.math.roundToInt object ModuleListPresenter : Presenter { @@ -42,18 +43,22 @@ object ModuleListPresenter : Presenter { val moduleItems: List = if (module.items.isNotEmpty()) { module.items.map { item -> if (item.type.equals(ModuleItem.Type.SubHeader.name, ignoreCase = true)) { - ModuleListItemData.ModuleItemData( - id = item.id, - title = null, - subtitle = item.title, - iconResId = null, - isPublished = item.published, - indent = item.indent * indentWidth, - tintColor = 0, - enabled = false + ModuleListItemData.SubHeader( + id = item.id, + title = item.title, + indent = item.indent * indentWidth, + enabled = false, + published = item.published, + isLoading = item.id in model.loadingModuleItemIds ) } else { - createModuleItemData(item, context, indentWidth, iconTint, item.id in model.loadingModuleItemIds) + createModuleItemData( + item, + context, + indentWidth, + iconTint, + item.id in model.loadingModuleItemIds + ) } } } else { @@ -63,7 +68,8 @@ object ModuleListPresenter : Presenter { id = module.id, name = module.name.orEmpty(), isPublished = module.published, - moduleItems = moduleItems + moduleItems = moduleItems, + isLoading = module.id in model.loadingModuleItemIds ) } @@ -98,12 +104,13 @@ object ModuleListPresenter : Presenter { loading: Boolean ): ModuleListItemData.ModuleItemData { val subtitle = item.moduleDetails?.dueDate?.let { - context.getString( - R.string.due, - DateHelper.getMonthDayTimeMaybeMinutesMaybeYear(context, it, R.string.at) - ) + DateHelper.getMonthDayTimeMaybeMinutesMaybeYear(context, it, R.string.at) } + val pointsPossible = item.moduleDetails?.pointsPossible?.toFloatOrNull() + val subtitle2 = + pointsPossible?.let { context.resources.getQuantityString(R.plurals.moduleItemPoints, it.toInt(), it) } + val iconRes: Int? = when (tryOrNull { ModuleItem.Type.valueOf(item.type.orEmpty()) }) { ModuleItem.Type.Assignment -> R.drawable.ic_assignment ModuleItem.Type.Discussion -> R.drawable.ic_discussion @@ -119,12 +126,16 @@ object ModuleListPresenter : Presenter { id = item.id, title = item.title, subtitle = subtitle, - iconResId = iconRes.takeUnless { loading }, + subtitle2 = subtitle2, + iconResId = iconRes, isPublished = item.published, indent = item.indent * indentWidth, tintColor = courseColor, enabled = !loading, - isLoading = loading + isLoading = loading, + type = tryOrNull { ModuleItem.Type.valueOf(item.type.orEmpty()) } ?: ModuleItem.Type.Assignment, + contentDetails = item.moduleDetails, + contentId = item.contentId ) } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt index 1087454574..2945615e33 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt @@ -16,8 +16,10 @@ */ package com.instructure.teacher.features.modules.list +import androidx.annotation.StringRes import com.instructure.canvasapi2.CanvasRestAdapter import com.instructure.canvasapi2.utils.patchedBy +import com.instructure.teacher.R import com.instructure.teacher.mobius.common.ui.UpdateInit import com.spotify.mobius.First import com.spotify.mobius.Next @@ -52,10 +54,12 @@ class ModuleListUpdate : UpdateInit { val item = model.modules.flatMap { it.items }.first { it.id == event.moduleItemId } return Next.dispatch(setOf(ModuleListEffect.ShowModuleItemDetailView(item, model.course))) } + is ModuleListEvent.PageLoaded -> { val effects = mutableSetOf() var newModel = model.copy( @@ -67,7 +71,8 @@ class ModuleListUpdate : UpdateInit module.items.any { it.id == model.scrollToItemId } }) { + && newModules.any { module -> module.items.any { it.id == model.scrollToItemId } } + ) { newModel = newModel.copy(scrollToItemId = null) effects += ModuleListEffect.ScrollToItem(model.scrollToItemId) } @@ -75,6 +80,7 @@ class ModuleListUpdate : UpdateInit { return if (model.isLoading || !model.pageData.hasMorePages) { // Do nothing if we're already loading or all pages have loaded @@ -89,13 +95,19 @@ class ModuleListUpdate : UpdateInit { - return Next.dispatch(setOf(ModuleListEffect.MarkModuleExpanded( - model.course, - event.moduleId, - event.isExpanded - ))) + return Next.dispatch( + setOf( + ModuleListEffect.MarkModuleExpanded( + model.course, + event.moduleId, + event.isExpanded + ) + ) + ) } + is ModuleListEvent.ModuleItemLoadStatusChanged -> { return Next.next( model.copy( @@ -107,6 +119,7 @@ class ModuleListUpdate : UpdateInit { val items = model.modules.flatMap { it.items }.filter { it.type == event.type }.filter(event.predicate) return if (items.isEmpty()) { @@ -116,6 +129,7 @@ class ModuleListUpdate : UpdateInit { val itemGroups = event.items.groupBy { it.moduleId } val newModel = model.copy( @@ -130,6 +144,7 @@ class ModuleListUpdate : UpdateInit { val newModel = model.copy( modules = model.modules.map { module -> @@ -141,6 +156,186 @@ class ModuleListUpdate : UpdateInit { + val affectedIds = mutableListOf(event.moduleId) + if (!event.skipContentTags) { + affectedIds.addAll(model.modules.filter { it.id == event.moduleId } + .flatMap { it.items } + .map { it.id }) + } + + val newModel = model.copy( + loadingModuleItemIds = model.loadingModuleItemIds + affectedIds + ) + val effect = ModuleListEffect.BulkUpdateModules( + model.course, + listOf(event.moduleId), + affectedIds, + event.action, + event.skipContentTags, + false + ) + return Next.next(newModel, setOf(effect)) + } + + is ModuleListEvent.BulkUpdateAllModules -> { + val affectedIds = mutableListOf() + affectedIds.addAll(model.modules.map { it.id }) + if (!event.skipContentTags) { + affectedIds.addAll(model.modules.flatMap { it.items }.map { it.id }) + } + + val newModel = model.copy( + loadingModuleItemIds = model.loadingModuleItemIds + affectedIds + ) + val effect = ModuleListEffect.BulkUpdateModules( + model.course, + model.modules.map { it.id }, + affectedIds, + event.action, + event.skipContentTags, + true + ) + return Next.next(newModel, setOf(effect)) + } + + is ModuleListEvent.BulkUpdateSuccess -> { + val newModel = model.copy( + isLoading = true, + modules = emptyList(), + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val effect = ModuleListEffect.LoadNextPage( + newModel.course, + newModel.pageData, + newModel.scrollToItemId + ) + + val message = getBulkUpdateSnackbarMessage(event.action, event.skipContentTags, event.allModules) + + val snackbarEffect = ModuleListEffect.ShowSnackbar(message) + + return Next.next(newModel, setOf(effect, snackbarEffect)) + } + + is ModuleListEvent.BulkUpdateFailed -> { + val newModel = model.copy( + loadingModuleItemIds = emptySet() + ) + + val snackbarEffect = ModuleListEffect.ShowSnackbar(R.string.errorOccurred) + + return Next.next(newModel, setOf(snackbarEffect)) + } + + is ModuleListEvent.UpdateModuleItem -> { + val newModel = model.copy( + loadingModuleItemIds = model.loadingModuleItemIds + event.itemId + ) + val effect = ModuleListEffect.UpdateModuleItem( + model.course, + model.modules.first { it.items.any { it.id == event.itemId } }.id, + event.itemId, + event.isPublished + ) + return Next.next(newModel, setOf(effect)) + } + + is ModuleListEvent.ModuleItemUpdateSuccess -> { + val newModel = model.copy( + modules = model.modules.map { module -> + if (event.item.moduleId == module.id) { + module.copy(items = module.items.patchedBy(listOf(event.item)) { it.id }) + } else { + module + } + }, + loadingModuleItemIds = model.loadingModuleItemIds - event.item.id + ) + + val snackbarEffect = + ModuleListEffect.ShowSnackbar(if (event.item.published == true) R.string.moduleItemPublished else R.string.moduleItemUnpublished) + + return Next.next(newModel, setOf(snackbarEffect)) + } + + is ModuleListEvent.ModuleItemUpdateFailed -> { + val newModel = model.copy( + loadingModuleItemIds = model.loadingModuleItemIds - event.itemId + ) + + val snackbarEffect = ModuleListEffect.ShowSnackbar(R.string.errorOccurred) + + return Next.next(newModel, setOf(snackbarEffect)) + } + + is ModuleListEvent.UpdateFileModuleItem -> { + val effect = ModuleListEffect.UpdateFileModuleItem( + event.fileId, + event.contentDetails + ) + return Next.dispatch(setOf(effect)) + } + + is ModuleListEvent.BulkUpdateCancelled -> { + val newModel = model.copy( + isLoading = true, + modules = emptyList(), + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val effect = ModuleListEffect.LoadNextPage( + newModel.course, + newModel.pageData, + newModel.scrollToItemId + ) + val snackbarEffect = ModuleListEffect.ShowSnackbar(R.string.updateCancelled) + return Next.next(newModel, setOf(effect, snackbarEffect)) + } + + is ModuleListEvent.BulkUpdateStarted -> { + val newModel = model.copy( + loadingModuleItemIds = model.loadingModuleItemIds + event.affectedIds + ) + val effect = ModuleListEffect.BulkUpdateStarted( + event.progressId, + event.allModules, + event.skipContentTags, + event.action + ) + return Next.next(newModel, setOf(effect)) + } + } + } + + @StringRes + private fun getBulkUpdateSnackbarMessage( + action: BulkModuleUpdateAction, + skipContentTags: Boolean, + allModules: Boolean + ): Int { + return if (allModules) { + if (action == BulkModuleUpdateAction.PUBLISH) { + if (skipContentTags) { + R.string.onlyModulesPublished + } else { + R.string.allModulesAndAllItemsPublished + } + } else { + R.string.allModulesAndAllItemsUnpublished + } + } else { + if (action == BulkModuleUpdateAction.PUBLISH) { + if (skipContentTags) { + R.string.onlyModulePublished + } else { + R.string.moduleAndAllItemsPublished + } + } else { + R.string.moduleAndAllItemsUnpublished + } } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt index 4c4358f49b..7c43150dbb 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt @@ -1,57 +1,67 @@ /* - * Copyright (C) 2019 - present Instructure, Inc. + * Copyright (C) 2024 - 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. + * 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 * - * 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. + * 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. * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . * */ + package com.instructure.teacher.features.modules.list.ui import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup +import android.view.View +import androidx.lifecycle.lifecycleScope +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.apis.ProgressAPI import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.utils.pageview.PageView -import com.instructure.pandautils.analytics.SCREEN_VIEW_MODULE_LIST -import com.instructure.pandautils.analytics.ScreenView +import com.instructure.pandautils.features.progress.ProgressPreferences +import com.instructure.pandautils.room.appdatabase.daos.ModuleBulkProgressDao import com.instructure.pandautils.utils.Const -import com.instructure.pandautils.utils.NLongArg -import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.withArgs -import com.instructure.teacher.databinding.FragmentModuleListBinding -import com.instructure.teacher.features.modules.list.* -import com.instructure.teacher.mobius.common.ui.MobiusFragment -import com.instructure.teacher.mobius.common.ui.Presenter +import com.instructure.teacher.features.modules.list.ModuleListEffectHandler +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject -@PageView(url = "{canvasContext}/modules") -@ScreenView(SCREEN_VIEW_MODULE_LIST) -class ModuleListFragment : MobiusFragment() { +@AndroidEntryPoint +class ModuleListFragment : ModuleListMobiusFragment() { - val canvasContext by ParcelableArg(key = Const.COURSE) + @Inject + lateinit var moduleApi: ModuleAPI.ModuleInterface - private val scrollToItemId by NLongArg(key = Const.MODULE_ITEM_ID) + @Inject + lateinit var progressApi: ProgressAPI.ProgressInterface - override fun makeEffectHandler() = ModuleListEffectHandler() + @Inject + lateinit var progressPreferences: ProgressPreferences - override fun makeUpdate() = ModuleListUpdate() + @Inject + lateinit var moduleBulkProgressDao: ModuleBulkProgressDao - override fun makeView(inflater: LayoutInflater, parent: ViewGroup) = ModuleListView(inflater, parent, canvasContext) + override fun makeEffectHandler() = ModuleListEffectHandler(moduleApi, progressApi, progressPreferences, moduleBulkProgressDao) - override fun makePresenter(): Presenter = ModuleListPresenter - - override fun makeInitModel(): ModuleListModel = ModuleListModel(course = canvasContext, scrollToItemId = scrollToItemId) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + retainInstance = false + } - override val eventSources = listOf(ModuleListEventBusSource()) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + lifecycleScope.launch { + val progresses = moduleBulkProgressDao.findByCourseId(canvasContext.id) + this@ModuleListFragment.view.bulkUpdateInProgress(progresses) + } + } companion object { @@ -63,5 +73,4 @@ class ModuleListFragment : MobiusFragment. + * + */ +package com.instructure.teacher.features.modules.list.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.utils.pageview.PageView +import com.instructure.pandautils.analytics.SCREEN_VIEW_MODULE_LIST +import com.instructure.pandautils.analytics.ScreenView +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.NLongArg +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.withArgs +import com.instructure.teacher.databinding.FragmentModuleListBinding +import com.instructure.teacher.features.modules.list.* +import com.instructure.teacher.mobius.common.ui.MobiusFragment +import com.instructure.teacher.mobius.common.ui.Presenter +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@PageView(url = "{canvasContext}/modules") +@ScreenView(SCREEN_VIEW_MODULE_LIST) +abstract class ModuleListMobiusFragment : MobiusFragment() { + + val canvasContext by ParcelableArg(key = Const.COURSE) + + private val scrollToItemId by NLongArg(key = Const.MODULE_ITEM_ID) + + override fun makeUpdate() = ModuleListUpdate() + + override fun makeView(inflater: LayoutInflater, parent: ViewGroup) = ModuleListView(inflater, parent, canvasContext) + + override fun makePresenter(): Presenter = ModuleListPresenter + + override fun makeInitModel(): ModuleListModel = ModuleListModel(course = canvasContext, scrollToItemId = scrollToItemId) + + override val eventSources = listOf(ModuleListEventBusSource()) + + + +} diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt index 5e8e6b20f5..5c1d808bf9 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt @@ -17,15 +17,28 @@ package com.instructure.teacher.features.modules.list.ui import android.content.Context +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.teacher.adapters.GroupedRecyclerAdapter import com.instructure.teacher.adapters.ListItemCallback -import com.instructure.teacher.features.modules.list.ui.binders.* +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListEmptyBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListEmptyItemBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListFullErrorBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListInlineErrorBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListItemBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListLoadingBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListModuleBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListSubHeaderBinder interface ModuleListCallback : ListItemCallback { fun retryNextPage() fun moduleItemClicked(moduleItemId: Long) fun markModuleExpanded(moduleId: Long, isExpanded: Boolean) + fun updateModuleItem(itemId: Long, isPublished: Boolean) + fun publishModule(moduleId: Long) + fun publishModuleAndItems(moduleId: Long) + fun unpublishModuleAndItems(moduleId: Long) + fun updateFileModuleItem(fileId: Long, contentDetails: ModuleContentDetails) } class ModuleListRecyclerAdapter( @@ -49,6 +62,7 @@ class ModuleListRecyclerAdapter( register(ModuleListItemBinder()) register(ModuleListLoadingBinder()) register(ModuleListEmptyItemBinder()) + register(ModuleListSubHeaderBinder()) } fun setData(items: List, collapsedModuleIds: Set) { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt index c635d19b38..ab97fbf527 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt @@ -18,14 +18,24 @@ package com.instructure.teacher.features.modules.list.ui import android.view.LayoutInflater import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentActivity import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.pandarecycler.PaginatedScrollListener +import com.instructure.pandautils.features.progress.ProgressDialogFragment +import com.instructure.pandautils.room.appdatabase.entities.ModuleBulkProgressEntity import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.showThemed +import com.instructure.teacher.R import com.instructure.teacher.databinding.FragmentModuleListBinding +import com.instructure.teacher.features.modules.list.BulkModuleUpdateAction import com.instructure.teacher.features.modules.list.ModuleListEvent +import com.instructure.teacher.features.modules.list.ui.file.UpdateFileDialogFragment import com.instructure.teacher.features.modules.progression.ModuleProgressionFragment import com.instructure.teacher.mobius.common.ui.MobiusView import com.instructure.teacher.router.RouteMatcher @@ -36,7 +46,11 @@ class ModuleListView( inflater: LayoutInflater, parent: ViewGroup, val course: CanvasContext -) : MobiusView(inflater, FragmentModuleListBinding::inflate, parent) { +) : MobiusView( + inflater, + FragmentModuleListBinding::inflate, + parent +) { private var consumer: Consumer? = null @@ -59,6 +73,58 @@ class ModuleListView( consumer?.accept(ModuleListEvent.ModuleExpanded(moduleId, isExpanded)) } + override fun publishModule(moduleId: Long) { + showConfirmationDialog( + R.string.publishDialogTitle, + R.string.publishModuleDialogMessage, + R.string.publishDialogPositiveButton, + R.string.cancel + ) { + consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.PUBLISH, true)) + } + } + + override fun publishModuleAndItems(moduleId: Long) { + showConfirmationDialog( + R.string.publishDialogTitle, + R.string.publishModuleAndItemsDialogMessage, + R.string.publishDialogPositiveButton, + R.string.cancel + ) { + consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.PUBLISH, false)) + } + } + + override fun unpublishModuleAndItems(moduleId: Long) { + showConfirmationDialog( + R.string.unpublishDialogTitle, + R.string.unpublishModuleAndItemsDialogMessage, + R.string.unpublishDialogPositiveButton, + R.string.cancel + ) { + consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.UNPUBLISH, false)) + } + } + + override fun updateFileModuleItem(fileId: Long, contentDetails: ModuleContentDetails) { + consumer?.accept( + ModuleListEvent.UpdateFileModuleItem( + fileId, + contentDetails + ) + ) + } + + override fun updateModuleItem(itemId: Long, isPublished: Boolean) { + val title = if (isPublished) R.string.publishDialogTitle else R.string.unpublishDialogTitle + val message = + if (isPublished) R.string.publishModuleItemDialogMessage else R.string.unpublishModuleItemDialogMessage + val positiveButton = if (isPublished) R.string.publishDialogPositiveButton else R.string.unpublishDialogPositiveButton + + showConfirmationDialog(title, message, positiveButton, R.string.cancel) { + consumer?.accept(ModuleListEvent.UpdateModuleItem(itemId, isPublished)) + } + } }) init { @@ -67,6 +133,58 @@ class ModuleListView( subtitle = course.name setupBackButton(activity) ViewStyler.themeToolbarColored(activity, this, course) + inflateMenu(R.menu.menu_module_list) + setOnMenuItemClickListener { + when (it.itemId) { + R.id.actionPublishModulesItems -> { + showConfirmationDialog( + R.string.publishDialogTitle, + R.string.publishModulesAndItemsDialogMessage, + R.string.publishDialogPositiveButton, + R.string.cancel + ) { + consumer?.accept( + ModuleListEvent.BulkUpdateAllModules( + BulkModuleUpdateAction.PUBLISH, + false + ) + ) + } + true + } + + R.id.actionPublishModules -> { + showConfirmationDialog( + R.string.publishDialogTitle, + R.string.publishModulesDialogMessage, + R.string.publishDialogPositiveButton, + R.string.cancel + ) { + consumer?.accept(ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.PUBLISH, true)) + } + true + } + + R.id.actionUnpublishModulesItems -> { + showConfirmationDialog( + R.string.unpublishDialogTitle, + R.string.unpublishModulesAndItemsDialogMessage, + R.string.unpublishDialogPositiveButton, + R.string.cancel + ) { + consumer?.accept( + ModuleListEvent.BulkUpdateAllModules( + BulkModuleUpdateAction.UNPUBLISH, + false + ) + ) + } + true + } + + else -> false + } + } } binding.recyclerView.apply { @@ -103,4 +221,50 @@ class ModuleListView( val itemPosition = adapter.getItemVisualPosition(itemId) binding.recyclerView.scrollToPosition(itemPosition) } + + fun showConfirmationDialog( + title: Int, + message: Int, + positiveButton: Int, + negativeButton: Int, + onConfirmed: () -> Unit + ) { + AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton(positiveButton) { _, _ -> + onConfirmed() + } + .setNegativeButton(negativeButton) { _, _ -> } + .showThemed() + } + + fun showSnackbar(@StringRes message: Int) { + Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show() + } + + fun showUpdateFileDialog(fileId: Long, contentDetails: ModuleContentDetails) { + val fragment = UpdateFileDialogFragment.newInstance(fileId, contentDetails, course) + fragment.show((activity as FragmentActivity).supportFragmentManager, "editFileDialog") + } + + fun showProgressDialog( + progressId: Long, + @StringRes title: Int, + @StringRes progressTitle: Int, + @StringRes note: Int? = null + ) { + val fragment = ProgressDialogFragment.newInstance( + progressId, + context.getString(title), + context.getString(progressTitle), + note?.let { context.getString(it) }) + fragment.show((activity as FragmentActivity).supportFragmentManager, "progressDialog") + } + + fun bulkUpdateInProgress(progresses: List) { + progresses.forEach { + consumer?.accept(ModuleListEvent.BulkUpdateStarted(course, it.progressId, it.allModules, it.skipContentTags, it.affectedIds, BulkModuleUpdateAction.valueOf(it.action))) + } + } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt index 5b000ef475..a330540f1d 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt @@ -17,6 +17,10 @@ package com.instructure.teacher.features.modules.list.ui import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.ModuleItem data class ModuleListViewState( val showRefreshing: Boolean = false, @@ -36,11 +40,21 @@ sealed class ModuleListItemData { data class InlineError(val buttonColor: Int): ModuleListItemData() + data class SubHeader( + val id: Long, + val title: String?, + val indent: Int, + val enabled: Boolean, + val published: Boolean?, + val isLoading: Boolean + ) : ModuleListItemData() + data class ModuleData( val id: Long, val name: String, val isPublished: Boolean?, - val moduleItems: List + val moduleItems: List, + val isLoading: Boolean ): ModuleListItemData() data class ModuleItemData( @@ -53,6 +67,9 @@ sealed class ModuleListItemData { /** The subtitle. If null, the subtitle should be hidden. */ val subtitle: String?, + /** The second line of subtitle. If null, it should be hidden. */ + val subtitle2: String?, + /** The resource ID of the icon to show for this item. If null, the icon should be hidden. */ val iconResId: Int?, @@ -73,7 +90,13 @@ sealed class ModuleListItemData { * Whether additional data is being loaded for this item, either for the purpose of routing or for the purpose * of refreshing this item after it has been updated elsewhere in the app. */ - val isLoading: Boolean = false + val isLoading: Boolean = false, + + val type: ModuleItem.Type, + + val contentDetails: ModuleContentDetails? = null, + + val contentId: Long? = null ) : ModuleListItemData() } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt index 7f6ef0fc00..d9f10c0484 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt @@ -16,7 +16,18 @@ */ package com.instructure.teacher.features.modules.list.ui.binders -import android.content.res.ColorStateList +import android.view.Gravity +import android.view.View +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.appcompat.widget.PopupMenu +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.utils.isValid +import com.instructure.pandautils.binding.setTint +import com.instructure.pandautils.utils.getFragmentActivity +import com.instructure.pandautils.utils.onClickWithRequireNetwork import com.instructure.pandautils.utils.setTextForVisibility import com.instructure.pandautils.utils.setVisible import com.instructure.teacher.R @@ -24,6 +35,7 @@ import com.instructure.teacher.adapters.ListItemBinder import com.instructure.teacher.databinding.AdapterModuleItemBinding import com.instructure.teacher.features.modules.list.ui.ModuleListCallback import com.instructure.teacher.features.modules.list.ui.ModuleListItemData +import com.instructure.teacher.features.modules.list.ui.file.UpdateFileDialogFragment class ModuleListItemBinder : ListItemBinder() { @@ -37,16 +49,109 @@ class ModuleListItemBinder : ListItemBinder { + if (data.contentDetails?.hidden == true) { + icon = R.drawable.ic_eye_off + tint = R.color.textWarning + contentDescription = R.string.a11y_hidden + } else if (data.contentDetails?.lockAt.isValid() || data.contentDetails?.unlockAt.isValid()) { + icon = R.drawable.ic_calendar_month + tint = R.color.textWarning + contentDescription = R.string.a11y_scheduled + } else { + icon = if (data.isPublished == true) R.drawable.ic_complete_solid else R.drawable.ic_no + tint = if (data.isPublished == true) R.color.textSuccess else R.color.textDark + contentDescription = if (data.isPublished == true) R.string.a11y_published else R.string.a11y_unpublished + } + } + + else -> { + icon = if (data.isPublished == true) R.drawable.ic_complete_solid else R.drawable.ic_no + tint = if (data.isPublished == true) R.color.textSuccess else R.color.textDark + contentDescription = if (data.isPublished == true) R.string.a11y_published else R.string.a11y_unpublished + } + } + + return StatusIcon(icon, tint, contentDescription) + } + + private fun showModuleItemActions( + view: View, + item: ModuleListItemData.ModuleItemData, + callback: ModuleListCallback + ) { + val popup = PopupMenu(view.context, view, Gravity.START.and(Gravity.TOP)) + val menu = popup.menu + + when (item.isPublished) { + true -> menu.add(0, 0, 0, R.string.unpublishModuleItemAction) + false -> menu.add(0, 1, 1, R.string.publishModuleItemAction) + else -> { + menu.add(0, 0, 0, R.string.unpublishModuleItemAction) + menu.add(0, 1, 1, R.string.publishModuleItemAction) + } } + + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + 0 -> { + callback.updateModuleItem(item.id, false) + true + } + + 1 -> { + callback.updateModuleItem(item.id, true) + true + } + + else -> false + } + } + + view.contentDescription = view.context.getString(R.string.a11y_contentDescription_moduleOptions, item.title) + popup.show() } } + +data class StatusIcon( + @DrawableRes val icon: Int, + @ColorRes val tint: Int, + @StringRes val contentDescription: Int +) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListModuleBinder.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListModuleBinder.kt index 74c6233299..bfbb353c73 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListModuleBinder.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListModuleBinder.kt @@ -16,6 +16,9 @@ */ package com.instructure.teacher.features.modules.list.ui.binders +import android.view.Gravity +import androidx.appcompat.widget.PopupMenu +import com.instructure.pandautils.utils.onClickWithRequireNetwork import com.instructure.pandautils.utils.setVisible import com.instructure.teacher.R import com.instructure.teacher.adapters.ListItemBinder @@ -31,13 +34,41 @@ class ModuleListModuleBinder : ListItemBinder callback.markModuleExpanded(item.id, isExpanded) }, - onBind = { item, view, isCollapsed, _ -> + onBind = { item, view, isCollapsed, callback -> val binding = AdapterModuleBinding.bind(view) with(binding) { moduleName.text = item.name - publishedIcon.setVisible(item.isPublished == true) - unpublishedIcon.setVisible(item.isPublished == false) - collapseIcon.rotation = if (isCollapsed) 0f else 180f + publishedIcon.setVisible(item.isPublished == true && !item.isLoading) + unpublishedIcon.setVisible(item.isPublished == false && !item.isLoading) + collapseIcon.rotation = if (isCollapsed) 180f else 0f + + loadingView.setVisible(item.isLoading) + + overflow.onClickWithRequireNetwork { + val popup = PopupMenu(it.context, it, Gravity.START.and(Gravity.TOP)) + popup.inflate(R.menu.menu_module) + + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.publishModuleItems -> { + callback.publishModuleAndItems(item.id) + true + } + R.id.publishModule -> { + callback.publishModule(item.id) + true + } + R.id.unpublishModuleItems -> { + callback.unpublishModuleAndItems(item.id) + true + } + else -> false + } + } + + overflow.contentDescription = it.context.getString(R.string.a11y_contentDescription_moduleOptions, item.name) + popup.show() + } } } ) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListSubHeaderBinder.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListSubHeaderBinder.kt new file mode 100644 index 0000000000..b7f663febc --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListSubHeaderBinder.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 - 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.teacher.features.modules.list.ui.binders + +import android.view.Gravity +import androidx.appcompat.widget.PopupMenu +import com.instructure.pandautils.utils.onClickWithRequireNetwork +import com.instructure.pandautils.utils.setHidden +import com.instructure.pandautils.utils.setVisible +import com.instructure.teacher.R +import com.instructure.teacher.adapters.ListItemBinder +import com.instructure.teacher.databinding.AdapterModuleSubHeaderBinding +import com.instructure.teacher.features.modules.list.ui.ModuleListCallback +import com.instructure.teacher.features.modules.list.ui.ModuleListItemData + +class ModuleListSubHeaderBinder : ListItemBinder() { + override val layoutResId = R.layout.adapter_module_sub_header + + override fun getItemId(item: ModuleListItemData.SubHeader) = item.id + + override val bindBehavior: BindBehavior = Item { item, view, callback -> + val binding = AdapterModuleSubHeaderBinding.bind(view) + with(binding) { + subHeaderTitle.text = item.title + moduleItemPublishedIcon.setVisible(item.published == true && !item.isLoading) + moduleItemUnpublishedIcon.setVisible(item.published == false && !item.isLoading) + moduleItemIndent.layoutParams.width = item.indent + + moduleItemLoadingView.setVisible(item.isLoading) + + overflow.onClickWithRequireNetwork { + val popup = PopupMenu(it.context, it, Gravity.START.and(Gravity.TOP)) + val menu = popup.menu + + when (item.published) { + true -> menu.add(0, 0, 0, R.string.unpublish) + false -> menu.add(0, 0, 0, R.string.publish) + else -> { + menu.add(0, 0, 0, R.string.unpublish) + menu.add(0, 1, 1, R.string.publish) + } + } + + overflow.contentDescription = it.context.getString(R.string.a11y_contentDescription_moduleOptions, item.title) + popup.show() + } + } + } +} \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileDialogFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileDialogFragment.kt new file mode 100644 index 0000000000..ae28d0c29c --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileDialogFragment.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2024 - 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.teacher.features.modules.list.ui.file + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CompoundButton +import androidx.core.widget.CompoundButtonCompat +import androidx.fragment.app.viewModels +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.pandautils.dialogs.DatePickerDialogFragment +import com.instructure.pandautils.dialogs.TimePickerDialogFragment +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.children +import com.instructure.pandautils.utils.textAndIconColor +import com.instructure.teacher.databinding.FragmentDialogUpdateFileBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class UpdateFileDialogFragment : BottomSheetDialogFragment() { + + private val canvasContext: CanvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) + + private lateinit var binding: FragmentDialogUpdateFileBinding + + private val viewModel: UpdateFileViewModel by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentDialogUpdateFileBinding.inflate(inflater, container, false) + binding.viewModel = viewModel + binding.lifecycleOwner = this + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.events.observe(viewLifecycleOwner) { + it.getContentIfNotHandled()?.let { + handleAction(it) + } + } + + setRadioButtonColors() + + binding.updateButton.setTextColor(canvasContext.textAndIconColor) + } + + private fun setRadioButtonColors() = with(binding) { + val radioButtonColor = ViewStyler.makeColorStateListForRadioGroup( + requireContext().getColor(com.instructure.pandautils.R.color.textDarkest), canvasContext.textAndIconColor + ) + + val radioButtons = + availabilityRadioGroup.children.filterIsInstance() + visibilityRadioGroup.children.filterIsInstance() + + radioButtons.forEach { + CompoundButtonCompat.setButtonTintList(it, radioButtonColor) + } + } + + private fun handleAction(event: UpdateFileEvent) { + when (event) { + is UpdateFileEvent.Close -> dismiss() + is UpdateFileEvent.ShowDatePicker -> { + val dialog = DatePickerDialogFragment.getInstance( + manager = childFragmentManager, + defaultDate = event.selectedDate, + minDate = event.minDate, + maxDate = event.maxDate, + callback = event.callback + ) + dialog.show(childFragmentManager, "datePicker") + } + + is UpdateFileEvent.ShowTimePicker -> { + val dialog = TimePickerDialogFragment.getInstance( + childFragmentManager, + event.selectedDate, + event.callback + ) + dialog.show(childFragmentManager, "timePicker") + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + setOnShowListener { + val bottomSheet = findViewById(com.google.android.material.R.id.design_bottom_sheet) + val behavior = BottomSheetBehavior.from(bottomSheet) + behavior.skipCollapsed = true + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.peekHeight = 0 + bottomSheet.parent.requestLayout() + } + } + } + + companion object { + + fun newInstance( + contentId: Long, + contentDetails: ModuleContentDetails?, + canvasContext: CanvasContext + ): UpdateFileDialogFragment { + return UpdateFileDialogFragment().apply { + arguments = Bundle().apply { + putLong("contentId", contentId) + putParcelable("contentDetails", contentDetails) + putParcelable(Const.CANVAS_CONTEXT, canvasContext) + } + } + + } + } +} \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewData.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewData.kt new file mode 100644 index 0000000000..0caa52feee --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewData.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 - 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.teacher.features.modules.list.ui.file + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.instructure.teacher.R +import java.util.Date + +data class UpdateFileViewData( + val selectedAvailability: FileAvailability, + val selectedVisibility: FileVisibility, + val lockAt: Date?, + val unlockAt: Date?, + val lockAtDateString: String?, + val lockAtTimeString: String?, + val unlockAtDateString: String?, + val unlockAtTimeString: String? +) + +enum class FileVisibility { + INHERIT, + CONTEXT, + INSTITUTION, + PUBLIC +} + +enum class FileAvailability { + PUBLISHED, + UNPUBLISHED, + HIDDEN, + SCHEDULED +} + +sealed class UpdateFileEvent { + object Close : UpdateFileEvent() + + data class ShowDatePicker( + val selectedDate: Date?, + val minDate: Date? = null, + val maxDate: Date? = null, + val callback: (year: Int, month: Int, dayOfMonth: Int) -> Unit + ) : UpdateFileEvent() + + data class ShowTimePicker( + val selectedDate: Date?, + val callback: (hourOfDay: Int, minute: Int) -> Unit + ) : UpdateFileEvent() +} \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewModel.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewModel.kt new file mode 100644 index 0000000000..454a20bc54 --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewModel.kt @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2024 - 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.teacher.features.modules.list.ui.file + +import android.annotation.SuppressLint +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.UpdateFileFolder +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.canvasapi2.utils.toApiString +import com.instructure.pandautils.mvvm.Event +import com.instructure.pandautils.mvvm.ViewState +import com.instructure.pandautils.utils.FileFolderUpdatedEvent +import com.instructure.teacher.R +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import java.util.Calendar +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +@SuppressLint("StaticFieldLeak") +class UpdateFileViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + @ApplicationContext private val context: Context, + private val fileApi: FileFolderAPI.FilesFoldersInterface, + private val eventBus: EventBus +) : ViewModel() { + + private val fileId: Long = savedStateHandle.get("contentId") ?: -1L + private val contentDetails: ModuleContentDetails = savedStateHandle.get("contentDetails") ?: ModuleContentDetails() + + val data: LiveData + get() = _data + private val _data = MutableLiveData() + + val events: LiveData> + get() = _events + private val _events = MutableLiveData>() + + val state: LiveData + get() = _state + private val _state = MutableLiveData() + + init { + _state.postValue(ViewState.Loading) + loadData() + } + + private fun loadData() { + viewModelScope.launch { + val file = fileApi.getFile(fileId, RestParams(isForceReadFromNetwork = true)).dataOrNull + + val availability = when { + contentDetails.locked == true -> FileAvailability.UNPUBLISHED + contentDetails.hidden == true -> FileAvailability.HIDDEN + contentDetails.lockAt != null || contentDetails.unlockAt != null -> FileAvailability.SCHEDULED + else -> FileAvailability.PUBLISHED + } + + val visibility = + file?.visibilityLevel?.let { FileVisibility.valueOf(it.uppercase()) } ?: FileVisibility.INHERIT + + _data.postValue( + UpdateFileViewData( + selectedAvailability = availability, + selectedVisibility = visibility, + lockAt = contentDetails.lockDate, + unlockAt = contentDetails.unlockDate, + lockAtDateString = DateHelper.getFormattedDate(context, contentDetails.lockDate), + lockAtTimeString = DateHelper.getFormattedTime(context, contentDetails.lockDate), + unlockAtDateString = DateHelper.getFormattedDate(context, contentDetails.unlockDate), + unlockAtTimeString = DateHelper.getFormattedTime(context, contentDetails.unlockDate), + ) + ) + _state.postValue(ViewState.Success) + } + } + + fun onAvailabilityChanged(availability: FileAvailability) { + _data.postValue( + data.value?.copy( + selectedAvailability = availability + ) + ) + } + + fun onVisibilityChanged(visibility: FileVisibility) { + _data.postValue( + data.value?.copy( + selectedVisibility = visibility + ) + ) + } + + fun close() { + _events.postValue(Event(UpdateFileEvent.Close)) + } + + fun update() { + viewModelScope.launch { + _state.postValue(ViewState.Loading) + val updateFileFolder = UpdateFileFolder( + locked = data.value?.selectedAvailability == FileAvailability.UNPUBLISHED, + hidden = data.value?.selectedAvailability == FileAvailability.HIDDEN, + lockAt = if (data.value?.selectedAvailability == FileAvailability.SCHEDULED) data.value?.lockAt?.toApiString() + .orEmpty() else "", + unlockAt = if (data.value?.selectedAvailability == FileAvailability.SCHEDULED) data.value?.unlockAt?.toApiString() + .orEmpty() else "", + visibilityLevel = data.value?.selectedVisibility?.name?.lowercase() + ) + val updatedFile = + fileApi.updateFile(fileId, updateFileFolder, RestParams(isForceReadFromNetwork = true)).dataOrNull + if (updatedFile != null) { + eventBus.post(FileFolderUpdatedEvent(updatedFile)) + _events.postValue(Event(UpdateFileEvent.Close)) + } else { + _state.postValue( + ViewState.Error( + context.getString(R.string.errorOccurred), + R.drawable.ic_panda_nofiles + ) + ) + } + } + } + + fun updateLockAt() { + _events.postValue(Event(UpdateFileEvent.ShowDatePicker(selectedDate = data.value?.lockAt, minDate = data.value?.unlockAt, callback = ::setLockAtDate))) + } + + fun updateLockTime() { + _events.postValue(Event(UpdateFileEvent.ShowTimePicker(data.value?.lockAt, ::setLockTime))) + } + + fun updateUnlockAt() { + _events.postValue(Event(UpdateFileEvent.ShowDatePicker(selectedDate = data.value?.unlockAt, maxDate = data.value?.lockAt, callback = ::setUnlockAtDate))) + } + + fun updateUnlockTime() { + _events.postValue(Event(UpdateFileEvent.ShowTimePicker(data.value?.unlockAt, ::setUnlockTime))) + } + + private fun setUnlockAtDate(year: Int, month: Int, day: Int) { + val selectedDate = getSelectedDate(data.value?.unlockAt, year, month, day) + _data.postValue( + data.value?.copy( + unlockAt = selectedDate, + unlockAtDateString = DateHelper.getFormattedDate(context, selectedDate), + unlockAtTimeString = DateHelper.getFormattedTime(context, selectedDate) + ) + ) + } + + private fun setLockAtDate(year: Int, month: Int, day: Int) { + val selectedDate = getSelectedDate(data.value?.lockAt, year, month, day) + _data.postValue( + data.value?.copy( + lockAt = selectedDate, + lockAtDateString = DateHelper.getFormattedDate(context, selectedDate), + lockAtTimeString = DateHelper.getFormattedTime(context, selectedDate) + ) + ) + } + + private fun setUnlockTime(hourOfDay: Int, minutes: Int) { + val selectedDate = getSelectedTime(data.value?.unlockAt, hourOfDay, minutes) + _data.postValue( + data.value?.copy( + unlockAt = selectedDate, + unlockAtDateString = DateHelper.getFormattedDate(context, selectedDate), + unlockAtTimeString = DateHelper.getFormattedTime(context, selectedDate) + ) + ) + } + + private fun setLockTime(hourOfDay: Int, minutes: Int) { + val selectedDate = getSelectedTime(data.value?.lockAt, hourOfDay, minutes) + _data.postValue( + data.value?.copy( + lockAt = selectedDate, + lockAtDateString = DateHelper.getFormattedDate(context, selectedDate), + lockAtTimeString = DateHelper.getFormattedTime(context, selectedDate) + ) + ) + } + + fun clearUnlockDate() { + _data.postValue( + data.value?.copy( + unlockAt = null, + unlockAtDateString = null, + unlockAtTimeString = null + ) + ) + } + + fun clearLockDate() { + _data.postValue( + data.value?.copy( + lockAt = null, + lockAtDateString = null, + lockAtTimeString = null + ) + ) + } + + private fun getSelectedDate(previousDate: Date?, year: Int, month: Int, day: Int): Date { + val calendar = Calendar.getInstance() + calendar.time = previousDate ?: Date() + calendar.set(year, month, day, 0, 0) + return calendar.time + } + + private fun getSelectedTime(previousDate: Date?, hourOfDay: Int, minutes: Int): Date { + val calendar = Calendar.getInstance() + calendar.time = previousDate ?: Date() + calendar.set(Calendar.HOUR_OF_DAY, hourOfDay) + calendar.set(Calendar.MINUTE, minutes) + return calendar.time + } +} \ No newline at end of file diff --git a/apps/teacher/src/main/res/layout/activity_route_validator.xml b/apps/teacher/src/main/res/layout/activity_route_validator.xml index ac79f0f7b2..d5922f088f 100644 --- a/apps/teacher/src/main/res/layout/activity_route_validator.xml +++ b/apps/teacher/src/main/res/layout/activity_route_validator.xml @@ -23,6 +23,12 @@ android:background="@color/backgroundLightest" android:orientation="vertical"> + + - + android:orientation="vertical"> - + android:background="@color/backgroundLight" + android:foreground="?attr/selectableItemBackground" + android:gravity="center_vertical" + android:minHeight="48dp" + android:orientation="horizontal" + android:paddingStart="8dp" + android:paddingTop="8dp" + android:paddingEnd="4dp" + android:paddingBottom="8dp"> - + + + + + + + + + - + - + +
diff --git a/apps/teacher/src/main/res/layout/adapter_module_item.xml b/apps/teacher/src/main/res/layout/adapter_module_item.xml index d4ffe7c6bd..58589b3f0d 100644 --- a/apps/teacher/src/main/res/layout/adapter_module_item.xml +++ b/apps/teacher/src/main/res/layout/adapter_module_item.xml @@ -1,5 +1,4 @@ - - - + android:paddingTop="12dp" + android:paddingEnd="4dp" + android:paddingBottom="12dp"> + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + android:tint="@color/textDark" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toEndOf="@id/moduleItemIndent" + app:layout_constraintTop_toTopOf="parent" /> - + - + android:layout_marginHorizontal="16dp" + android:ellipsize="end" + android:maxLines="1" + android:textColor="@color/textDark" + app:layout_constraintBottom_toTopOf="@+id/moduleItemSubtitle2" + app:layout_constraintEnd_toStartOf="@+id/statusWrapper" + app:layout_constraintStart_toEndOf="@id/moduleItemIcon" + app:layout_constraintTop_toBottomOf="@id/moduleItemTitle" + tools:text="Due Apr 25 at 11:59pm" /> - + - + - + - + + + + android:id="@+id/overflow" + android:layout_width="48dp" + android:layout_height="48dp" + android:background="?android:selectableItemBackground" + android:clickable="true" + android:contentDescription="@string/moduleOptions" + android:focusable="true" + android:paddingStart="14dp" + android:paddingTop="14dp" + android:paddingEnd="14dp" + android:paddingBottom="14dp" + android:tint="@color/textDark" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:srcCompat="@drawable/ic_overflow_white_18dp" /> - + diff --git a/apps/teacher/src/main/res/layout/adapter_module_sub_header.xml b/apps/teacher/src/main/res/layout/adapter_module_sub_header.xml new file mode 100644 index 0000000000..ab3f7db8a1 --- /dev/null +++ b/apps/teacher/src/main/res/layout/adapter_module_sub_header.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml b/apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml new file mode 100644 index 0000000000..03074bbaa0 --- /dev/null +++ b/apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml @@ -0,0 +1,452 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/teacher/src/main/res/menu/menu_module.xml b/apps/teacher/src/main/res/menu/menu_module.xml new file mode 100644 index 0000000000..6f8b60a129 --- /dev/null +++ b/apps/teacher/src/main/res/menu/menu_module.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/teacher/src/main/res/menu/menu_module_list.xml b/apps/teacher/src/main/res/menu/menu_module_list.xml new file mode 100644 index 0000000000..a5d56562b9 --- /dev/null +++ b/apps/teacher/src/main/res/menu/menu_module_list.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/teacher/src/main/res/values-ja/strings.xml b/apps/teacher/src/main/res/values-ja/strings.xml index 15921efd04..65996d9873 100644 --- a/apps/teacher/src/main/res/values-ja/strings.xml +++ b/apps/teacher/src/main/res/values-ja/strings.xml @@ -187,7 +187,7 @@ グループ内にユーザーなし 公開済み - 未公開 + 非公開 公開されたチェックマーク ポイント ポイント @@ -677,7 +677,7 @@ 新しいディスカッション オプション - 定期購読 + 常に通知 スレッドでの返信を許可する ユーザは返信を表示する前に投稿しなければなりません ユーザーにコメントを許可する @@ -806,7 +806,7 @@ ファイルを作成する ファイルの作成オタンとフォルダの作成ボタンを表示する ファイルの作成とフォルダの作成ボタンを非表示にする - 未公開 + 非公開 制限されたアクセス 部外秘 非表示の内部ファイルはリンクで利用できます。 @@ -826,7 +826,7 @@ ページを削除 これにより、ページは削除されます。この操作は元に戻すことはできません。 この発表を保存しようとしてエラーが発生しました。もう一度試してください。 - ページを先フロントページにした場合、未公開にすることはできません。 + ページを先フロントページにした場合、非公開にすることはできません。 教員のみ 教員と受講者 diff --git a/apps/teacher/src/qa/java/com/instructure/teacher/SingleFragmentTestActivity.kt b/apps/teacher/src/qa/java/com/instructure/teacher/SingleFragmentTestActivity.kt index be89c34695..50125e5773 100644 --- a/apps/teacher/src/qa/java/com/instructure/teacher/SingleFragmentTestActivity.kt +++ b/apps/teacher/src/qa/java/com/instructure/teacher/SingleFragmentTestActivity.kt @@ -23,7 +23,9 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import com.instructure.pandautils.binding.viewBinding import com.instructure.teacher.databinding.ActivitySingleFragmentTestBinding +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class SingleFragmentTestActivity : AppCompatActivity() { private val binding by viewBinding(ActivitySingleFragmentTestBinding::inflate) diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt index fc93c7acc4..c49520ad71 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt @@ -15,15 +15,24 @@ */ package com.instructure.teacher.unit.modules.list +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.apis.ProgressAPI import com.instructure.canvasapi2.managers.ModuleManager import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.canvasapi2.models.Progress +import com.instructure.canvasapi2.models.postmodels.BulkUpdateProgress +import com.instructure.canvasapi2.models.postmodels.BulkUpdateResponse import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.Failure import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.awaitApiResponse +import com.instructure.pandautils.features.progress.ProgressPreferences +import com.instructure.pandautils.room.appdatabase.daos.ModuleBulkProgressDao +import com.instructure.teacher.features.modules.list.BulkModuleUpdateAction import com.instructure.teacher.features.modules.list.CollapsedModulesStore import com.instructure.teacher.features.modules.list.ModuleListEffect import com.instructure.teacher.features.modules.list.ModuleListEffectHandler @@ -33,6 +42,7 @@ import com.instructure.teacher.features.modules.list.ui.ModuleListView import com.spotify.mobius.Connection import com.spotify.mobius.functions.Consumer import io.mockk.Ordering +import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.confirmVerified import io.mockk.every @@ -48,6 +58,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.test.setMain import okhttp3.Headers.Companion.toHeaders +import org.junit.After import org.junit.Assert import org.junit.Before import org.junit.Test @@ -62,14 +73,31 @@ class ModuleListEffectHandlerTest : Assert() { private lateinit var connection: Connection private val course: CanvasContext = Course() + private val moduleApi: ModuleAPI.ModuleInterface = mockk(relaxed = true) + private val progressApi: ProgressAPI.ProgressInterface = mockk(relaxed = true) + private val progressPreferences: ProgressPreferences = mockk(relaxed = true) + private val moduleBulkProgressDao: ModuleBulkProgressDao = mockk(relaxed = true) + @ExperimentalCoroutinesApi @Before fun setUp() { Dispatchers.setMain(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) view = mockk(relaxed = true) - effectHandler = ModuleListEffectHandler().apply { view = this@ModuleListEffectHandlerTest.view } + effectHandler = + ModuleListEffectHandler(moduleApi, progressApi, progressPreferences, moduleBulkProgressDao).apply { + view = this@ModuleListEffectHandlerTest.view + } consumer = mockk(relaxed = true) connection = effectHandler.connect(consumer) + + every { progressPreferences.cancelledProgressIds } returns mutableSetOf() + every { progressPreferences.cancelledProgressIds = any() } returns Unit + coEvery { moduleBulkProgressDao.findByCourseId(any()) } returns emptyList() + } + + @After + fun teardown() { + clearAllMocks() } @Test @@ -191,7 +219,8 @@ class ModuleListEffectHandlerTest : Assert() { val nextUrl1 = "fake_next_url_1" val nextUrl2 = "fake_next_url_2" val firstPageModules = makeModulePage(pageNumber = 0) - val secondPageModules = listOf(ModuleObject(id = moduleId, itemCount = 1, items = listOf(ModuleItem(scrollToItemId)))) + val secondPageModules = + listOf(ModuleObject(id = moduleId, itemCount = 1, items = listOf(ModuleItem(scrollToItemId)))) val thirdPageModules = makeModulePage(pageNumber = 1) val expectedEvent = ModuleListEvent.PageLoaded( @@ -281,6 +310,240 @@ class ModuleListEffectHandlerTest : Assert() { unmockkStatic("com.instructure.canvasapi2.utils.weave.AwaitApiKt") } + @Test + fun `BulkUpdateStarted results in correct success event`() { + val pageModules = makeModulePage() + val expectedEvent = ModuleListEvent.BulkUpdateSuccess(false, BulkModuleUpdateAction.PUBLISH, false) + + coEvery { + moduleApi.bulkUpdateModules( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success( + Progress( + 1L, + workflowState = "completed" + ) + ) + + connection.accept( + ModuleListEffect.BulkUpdateStarted( + 1L, + false, + false, + BulkModuleUpdateAction.PUBLISH + ) + ) + + verify(timeout = 1000) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + @Test + fun `BulkUpdateModules results in correct failed event when call fails`() { + val pageModules = makeModulePage() + val expectedEvent = ModuleListEvent.BulkUpdateFailed(false) + + coEvery { + moduleApi.bulkUpdateModules( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns DataResult.Fail() + + connection.accept( + ModuleListEffect.BulkUpdateModules( + course, + pageModules.map { it.id }, + pageModules.map { it.id } + pageModules.flatMap { it.items.map { it.id } }, + BulkModuleUpdateAction.PUBLISH, + false, + false + ) + ) + + verify(timeout = 1000) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + @Test + fun `BulkUpdateStarted results in correct failed event when progress fails`() { + val pageModules = makeModulePage() + val expectedEvent = ModuleListEvent.BulkUpdateFailed(false) + + coEvery { + moduleApi.bulkUpdateModules( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success( + Progress( + 1L, + workflowState = "failed" + ) + ) + + connection.accept( + ModuleListEffect.BulkUpdateStarted( + 1L, + false, + false, + BulkModuleUpdateAction.PUBLISH + ) + ) + + verify(timeout = 1000) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + @Test + fun `UpdateModuleItem results in correct success event`() { + val moduleId = 1L + val itemId = 2L + val expectedEvent = ModuleListEvent.ModuleItemUpdateSuccess( + ModuleItem(id = itemId, moduleId = moduleId, published = true), + true + ) + + coEvery { moduleApi.publishModuleItem(any(), any(), any(), any(), any(), any()) } returns DataResult.Success( + ModuleItem(2L, 1L, published = true) + ) + + connection.accept(ModuleListEffect.UpdateModuleItem(course, moduleId, itemId, true)) + + verify(timeout = 100) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + @Test + fun `UpdateModuleItem results in correct failed event`() { + val moduleId = 1L + val itemId = 2L + val expectedEvent = ModuleListEvent.ModuleItemUpdateFailed(itemId) + + coEvery { moduleApi.publishModuleItem(any(), any(), any(), any(), any(), any()) } returns DataResult.Fail() + + connection.accept(ModuleListEffect.UpdateModuleItem(course, moduleId, itemId, true)) + + verify(timeout = 100) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + @Test + fun `ShowSnackbar calls showSnackbar on view`() { + val message = 123 + connection.accept(ModuleListEffect.ShowSnackbar(message)) + verify(timeout = 100) { view.showSnackbar(message) } + confirmVerified(view) + } + + @Test + fun `UpdateFileModuleItem calls showUpdateFileDialog on view`() { + val fileId = 123L + val contentDetails = ModuleContentDetails() + connection.accept(ModuleListEffect.UpdateFileModuleItem(fileId, contentDetails)) + verify(timeout = 100) { view.showUpdateFileDialog(fileId, contentDetails) } + confirmVerified(view) + } + + @Test + fun `Bulk update cancel emits correct event`() { + val pageModules = makeModulePage() + val expectedEvent = ModuleListEvent.BulkUpdateCancelled + + coEvery { + moduleApi.bulkUpdateModules( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns DataResult.Success( + BulkUpdateResponse(BulkUpdateProgress(Progress(id = 1L))) + ) + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success( + Progress( + 1L, + workflowState = "failed" + ) + ) + every { progressPreferences.cancelledProgressIds } returns mutableSetOf(1L) + + connection.accept( + ModuleListEffect.BulkUpdateStarted( + 1L, + false, + false, + BulkModuleUpdateAction.PUBLISH + ) + ) + + verify(timeout = 1000) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + @Test + fun `BulkUpdateModules result in correct event`() { + val pageModules = makeModulePage() + val expectedEvent = ModuleListEvent.BulkUpdateStarted( + course, + 0L, + true, + false, + pageModules.map { it.id } + pageModules.flatMap { it.items.map { it.id } }, + BulkModuleUpdateAction.PUBLISH) + + coEvery { + moduleApi.bulkUpdateModules( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + + connection.accept( + ModuleListEffect.BulkUpdateModules( + course, + pageModules.map { it.id }, + pageModules.map { it.id } + pageModules.flatMap { it.items.map { it.id } }, + BulkModuleUpdateAction.PUBLISH, + false, + true + ) + ) + + verify(timeout = 1000) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + private fun makeLinkHeader(nextUrl: String) = + mapOf("Link" to """<$nextUrl>; rel="next"""").toHeaders() + private fun makeModulePage( moduleCount: Int = 3, itemsPerModule: Int = 3, @@ -296,8 +559,4 @@ class ModuleListEffectHandlerTest : Assert() { } } - - private fun makeLinkHeader(nextUrl: String) = - mapOf("Link" to """<$nextUrl>; rel="next"""").toHeaders() - } diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt index a26de81beb..25ee3f90c1 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt @@ -63,6 +63,7 @@ class ModuleListPresenterTest : Assert() { id = 1L, name = "Module 1", isPublished = true, + isLoading = false, moduleItems = listOf(ModuleListItemData.EmptyItem(1L)) ) moduleItemTemplate = ModuleItem( @@ -78,12 +79,18 @@ class ModuleListPresenterTest : Assert() { moduleItemDataTemplate = ModuleListItemData.ModuleItemData( id = 1000L, title = "Module Item 1", - subtitle = "Due February 12, 2050 at 3:07 PM", + subtitle = "February 12, 2050 at 3:07 PM", + subtitle2 = null, iconResId = R.drawable.ic_assignment, isPublished = true, indent = 0, tintColor = course.backgroundColor, - enabled = true + enabled = true, + type = ModuleItem.Type.Assignment, + contentDetails = ModuleContentDetails( + dueAt = DateHelper.makeDate(2050, 1, 12, 15, 7, 0).toApiString() + ), + contentId = 0 ) modelTemplate = ModuleListModel( course = course, @@ -210,7 +217,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_discussion + iconResId = R.drawable.ic_discussion, + type = ModuleItem.Type.Discussion ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -230,7 +238,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_attachment + iconResId = R.drawable.ic_attachment, + type = ModuleItem.Type.File ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -250,7 +259,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_pages + iconResId = R.drawable.ic_pages, + type = ModuleItem.Type.Page ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -270,7 +280,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_quiz + iconResId = R.drawable.ic_quiz, + type = ModuleItem.Type.Quiz ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -290,7 +301,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_link + iconResId = R.drawable.ic_link, + type = ModuleItem.Type.ExternalUrl ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -310,7 +322,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_lti + iconResId = R.drawable.ic_lti, + type = ModuleItem.Type.ExternalTool ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -328,12 +341,7 @@ class ModuleListPresenterTest : Assert() { moduleTemplate.copy(items = listOf(item)) ) ) - val expectedState = moduleItemDataTemplate.copy( - title = null, - subtitle = item.title, - iconResId = null, - enabled = false - ) + val expectedState = ModuleListItemData.SubHeader(1000L, "This is a header", 0, false, true, false) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() assertEquals(expectedState, itemState) @@ -353,9 +361,10 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = null, enabled = false, - isLoading = true + isLoading = true, + iconResId = R.drawable.ic_attachment, + type = ModuleItem.Type.File ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt index eaa44b83ea..bd0cc4e2b9 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt @@ -17,9 +17,11 @@ package com.instructure.teacher.unit.modules.list import com.instructure.canvasapi2.CanvasRestAdapter import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.utils.DataResult +import com.instructure.teacher.features.modules.list.BulkModuleUpdateAction import com.instructure.teacher.features.modules.list.ModuleListEffect import com.instructure.teacher.features.modules.list.ModuleListEvent import com.instructure.teacher.features.modules.list.ModuleListModel @@ -41,6 +43,7 @@ import io.mockk.mockkObject import io.mockk.unmockkObject import org.junit.Assert import org.junit.Test +import com.instructure.teacher.R class ModuleListUpdateTest : Assert() { @@ -196,7 +199,7 @@ class ModuleListUpdateTest : Assert() { val model = initModel.copy( modules = listOf(ModuleObject(items = items)) ) - val event = ModuleListEvent.ItemRefreshRequested("Discussion") { it.id in 1L..3L} + val event = ModuleListEvent.ItemRefreshRequested("Discussion") { it.id in 1L..3L } val expectedEffect = ModuleListEffect.UpdateModuleItems(model.course, listOf(items[1])) updateSpec .given(model) @@ -218,7 +221,7 @@ class ModuleListUpdateTest : Assert() { val model = initModel.copy( modules = listOf(ModuleObject(items = items)) ) - val event = ModuleListEvent.ItemRefreshRequested("Discussion") { it.id == 3L} + val event = ModuleListEvent.ItemRefreshRequested("Discussion") { it.id == 3L } updateSpec .given(model) .whenEvent(event) @@ -414,4 +417,502 @@ class ModuleListUpdateTest : Assert() { ) } + @Test + fun `BulkUpdateModule sets loading for module`() { + val module = ModuleObject(id = 1L, items = listOf(ModuleItem(id = 100L, moduleId = 1L))) + val event = ModuleListEvent.BulkUpdateModule(1L, BulkModuleUpdateAction.PUBLISH, true) + val model = initModel.copy(modules = listOf(module)) + val expectedModel = model.copy( + loadingModuleItemIds = setOf(1L) + ) + val expectedEffect = ModuleListEffect.BulkUpdateModules( + expectedModel.course, + listOf(1L), + listOf(1L), + BulkModuleUpdateAction.PUBLISH, + true, + false + ) + + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `BulkUpdateModule sets loading for module and items`() { + val module = ModuleObject(id = 1L, items = listOf(ModuleItem(id = 100L, moduleId = 1L))) + val event = ModuleListEvent.BulkUpdateModule(1L, BulkModuleUpdateAction.PUBLISH, false) + val model = initModel.copy(modules = listOf(module)) + val expectedModel = model.copy( + loadingModuleItemIds = setOf(1L, 100L) + ) + val expectedEffect = ModuleListEffect.BulkUpdateModules( + expectedModel.course, + listOf(1L), + listOf(1L, 100L), + BulkModuleUpdateAction.PUBLISH, + false, + false + ) + + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `BulkUpdateAllModules sets loading for modules`() { + val modules = listOf( + ModuleObject(id = 1L, items = listOf(ModuleItem(id = 100L, moduleId = 1L))), + ModuleObject( + id = 2L, items = listOf( + ModuleItem(id = 200L, moduleId = 2L), + ModuleItem(id = 201L, moduleId = 2L) + ) + ) + ) + val event = ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.UNPUBLISH, true) + val model = initModel.copy(modules = modules) + val expectedModel = model.copy( + loadingModuleItemIds = setOf(1L, 2L) + ) + val expectedEffect = ModuleListEffect.BulkUpdateModules( + expectedModel.course, + listOf(1L, 2L), + listOf(1L, 2L), + BulkModuleUpdateAction.UNPUBLISH, + true, + true + ) + + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `BulkUpdateAllModules sets loading for modules and items`() { + val modules = listOf( + ModuleObject(id = 1L, items = listOf(ModuleItem(id = 100L, moduleId = 1L))), + ModuleObject( + id = 2L, items = listOf( + ModuleItem(id = 200L, moduleId = 2L), + ModuleItem(id = 201L, moduleId = 2L) + ) + ) + ) + val event = ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.UNPUBLISH, false) + val model = initModel.copy(modules = modules) + val expectedModel = model.copy( + loadingModuleItemIds = setOf(1L, 100L, 2L, 200L, 201L) + ) + val expectedEffect = ModuleListEffect.BulkUpdateModules( + expectedModel.course, + listOf(1L, 2L), + listOf(1L, 2L, 100L, 200L, 201L), + BulkModuleUpdateAction.UNPUBLISH, + false, + true + ) + + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess emits refresh effect and clears loading`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf(ModuleObject(1L)), + loadingModuleItemIds = setOf(1L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.onlyModulesPublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(true, BulkModuleUpdateAction.PUBLISH, true)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess publish all modules and items displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), + loadingModuleItemIds = setOf(1L, 100L, 2L, 200L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.allModulesAndAllItemsPublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(false, BulkModuleUpdateAction.PUBLISH, true)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess unpublish all modules and items displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), + loadingModuleItemIds = setOf(1L, 100L, 2L, 200L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.allModulesAndAllItemsUnpublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(false, BulkModuleUpdateAction.UNPUBLISH, true)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess publish all modules displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), + loadingModuleItemIds = setOf(1L, 2L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.onlyModulesPublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(true, BulkModuleUpdateAction.PUBLISH, true)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess publish single module with all items displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), + loadingModuleItemIds = setOf(1L, 100L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.moduleAndAllItemsPublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(false, BulkModuleUpdateAction.PUBLISH, false)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess unpublish single module with all items displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), + loadingModuleItemIds = setOf(1L, 100L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.moduleAndAllItemsUnpublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(false, BulkModuleUpdateAction.UNPUBLISH, false)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess publish single module displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), + loadingModuleItemIds = setOf(1L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.onlyModulePublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(true, BulkModuleUpdateAction.PUBLISH, false)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateFailed clears loading`() { + val model = initModel.copy( + modules = listOf(ModuleObject(1L)), + loadingModuleItemIds = setOf(1L) + ) + + val expectedModel = model.copy( + loadingModuleItemIds = emptySet() + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.errorOccurred) + + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateFailed(false)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedSnackbarEffect) + ) + ) + } + + @Test + fun `UpdateModuleItem emits UpdateModuleItem effect`() { + val model = initModel.copy( + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L)))), + ) + val expectedModel = model.copy( + loadingModuleItemIds = setOf(100L) + ) + val expectedEffect = ModuleListEffect.UpdateModuleItem( + model.course, + 1L, + 100L, + true + ) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.UpdateModuleItem(100L, true)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `ModuleItemUpdateSuccess replaces module item`() { + val model = initModel.copy( + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L, 1L, published = false)))), + loadingModuleItemIds = setOf(100L) + ) + val expectedModel = initModel.copy( + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L, 1L, published = true)))), + loadingModuleItemIds = emptySet() + ) + val event = ModuleListEvent.ModuleItemUpdateSuccess(ModuleItem(100L, 1L, published = true), true) + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel) + ) + ) + } + + @Test + fun `ModuleItemUpdateFailed clears loading`() { + val model = initModel.copy( + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L, 1L, published = false)))), + loadingModuleItemIds = setOf(100L) + ) + val expectedModel = model.copy( + loadingModuleItemIds = emptySet() + ) + val event = ModuleListEvent.ModuleItemUpdateFailed(100L) + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel) + ) + ) + } + + @Test + fun `UpdateFileModuleItem emits UpdateFileModuleItem effect`() { + val model = initModel.copy( + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L, 1L, published = false)))), + ) + val expectedEffect = ModuleListEffect.UpdateFileModuleItem( + 100L, + ModuleContentDetails() + ) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.UpdateFileModuleItem(100L, ModuleContentDetails())) + .then( + assertThatNext( + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `BulkUpdateCancelled emits LoadNextPage effect`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), + loadingModuleItemIds = setOf(1L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val snackbarEffect = ModuleListEffect.ShowSnackbar(R.string.updateCancelled) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateCancelled) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, snackbarEffect) + ) + ) + } + + } diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/file/UpdateFileViewModelTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/file/UpdateFileViewModelTest.kt new file mode 100644 index 0000000000..16a3f38d19 --- /dev/null +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/file/UpdateFileViewModelTest.kt @@ -0,0 +1,647 @@ +/* + * Copyright (C) 2024 - 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.teacher.unit.modules.list.file + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.models.FileFolder +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.UpdateFileFolder +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.canvasapi2.utils.toApiString +import com.instructure.pandautils.mvvm.ViewState +import com.instructure.pandautils.utils.FileFolderUpdatedEvent +import com.instructure.teacher.features.modules.list.ui.file.FileAvailability +import com.instructure.teacher.features.modules.list.ui.file.FileVisibility +import com.instructure.teacher.features.modules.list.ui.file.UpdateFileEvent +import com.instructure.teacher.features.modules.list.ui.file.UpdateFileViewModel +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.setMain +import org.greenrobot.eventbus.EventBus +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date + +@ExperimentalCoroutinesApi +class UpdateFileViewModelTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + private val lifecycleOwner: LifecycleOwner = mockk(relaxed = true) + private val lifecycleRegistry = LifecycleRegistry(lifecycleOwner) + + private val testDispatcher = UnconfinedTestDispatcher() + + private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) + private val context: Context = mockk(relaxed = true) + private val fileApi: FileFolderAPI.FilesFoldersInterface = mockk(relaxed = true) + private val eventBus: EventBus = mockk(relaxed = true) + + private lateinit var viewModel: UpdateFileViewModel + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd") + private val timeFormat = SimpleDateFormat("HH:mm") + + @Before + fun setUp() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + Dispatchers.setMain(testDispatcher) + + mockkObject(DateHelper) + val dateCaptor = slot() + every { DateHelper.getFormattedDate(any(), capture(dateCaptor)) } answers { + dateFormat.format(dateCaptor.captured) + } + val timeCaptor = slot() + every { DateHelper.getFormattedTime(any(), capture(timeCaptor)) } answers { + timeFormat.format(timeCaptor.captured) + } + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `loadData sets correct state`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(ViewState.Success, viewModel.state.value) + } + + @Test + fun `Error during file fetching sets visibility to Inherit`() { + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Fail() + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileVisibility.INHERIT, viewModel.data.value?.selectedVisibility) + assert(viewModel.state.value is ViewState.Success) + } + + @Test + fun `Published file maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileAvailability.PUBLISHED, viewModel.data.value?.selectedAvailability) + assert(viewModel.state.value is ViewState.Success) + } + + @Test + fun `Unpublished file maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = true) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileAvailability.UNPUBLISHED, viewModel.data.value?.selectedAvailability) + } + + @Test + fun `Hidden file maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = true, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileAvailability.HIDDEN, viewModel.data.value?.selectedAvailability) + } + + @Test + fun `Scheduled file maps correctly`() { + val calendar = Calendar.getInstance() + val unlockDate = calendar.time + calendar.add(Calendar.DAY_OF_YEAR, 1) + val lockDate = calendar.time + + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails( + hidden = false, + locked = false, + lockAt = lockDate.toApiString(), + unlockAt = unlockDate.toApiString() + ) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileAvailability.SCHEDULED, viewModel.data.value?.selectedAvailability) + assertEquals(dateFormat.format(lockDate), viewModel.data.value?.lockAtDateString) + assertEquals(timeFormat.format(lockDate), viewModel.data.value?.lockAtTimeString) + assertEquals(dateFormat.format(unlockDate), viewModel.data.value?.unlockAtDateString) + assertEquals(timeFormat.format(unlockDate), viewModel.data.value?.unlockAtTimeString) + } + + @Test + fun `Inherit visibility maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.INHERIT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileVisibility.INHERIT, viewModel.data.value?.selectedVisibility) + } + + @Test + fun `Context visibility maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileVisibility.CONTEXT, viewModel.data.value?.selectedVisibility) + } + + @Test + fun `Institution visibility maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.INSTITUTION.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileVisibility.INSTITUTION, viewModel.data.value?.selectedVisibility) + } + + @Test + fun `Public visibility maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileVisibility.PUBLIC, viewModel.data.value?.selectedVisibility) + } + + @Test + fun `Availability change updates data`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.HIDDEN) + + assertEquals(FileAvailability.HIDDEN, viewModel.data.value?.selectedAvailability) + } + + @Test + fun `Visibility change updates data`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onVisibilityChanged(FileVisibility.CONTEXT) + + assertEquals(FileVisibility.CONTEXT, viewModel.data.value?.selectedVisibility) + } + + @Test + fun `Close emits correct event`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel = createViewModel() + viewModel.close() + + assertEquals(UpdateFileEvent.Close, viewModel.events.value?.getContentIfNotHandled()) + } + + @Test + fun `Update lock at date`() { + val calendar = Calendar.getInstance() + calendar.add(Calendar.DAY_OF_YEAR, -1) + calendar.set(Calendar.HOUR_OF_DAY, 0) + calendar.set(Calendar.MINUTE, 0) + + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, lockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateLockAt() + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowDatePicker) + (event as UpdateFileEvent.ShowDatePicker).callback( + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH) + ) + + assertEquals(dateFormat.format(calendar.time), viewModel.data.value?.lockAtDateString) + assertEquals(timeFormat.format(calendar.time), viewModel.data.value?.lockAtTimeString) + } + + @Test + fun `Update lock at time`() { + val calendar = Calendar.getInstance() + + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, lockAt = calendar.time.toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateLockTime() + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowTimePicker) + + calendar.add(Calendar.HOUR_OF_DAY, 1) + calendar.add(Calendar.MINUTE, 1) + (event as UpdateFileEvent.ShowTimePicker).callback( + calendar.get(Calendar.HOUR_OF_DAY), + calendar.get(Calendar.MINUTE) + ) + + assertEquals(dateFormat.format(calendar.time), viewModel.data.value?.lockAtDateString) + assertEquals(timeFormat.format(calendar.time), viewModel.data.value?.lockAtTimeString) + } + + @Test + fun `Update unlock at date`() { + val calendar = Calendar.getInstance() + calendar.add(Calendar.DAY_OF_YEAR, -1) + calendar.set(Calendar.HOUR_OF_DAY, 0) + calendar.set(Calendar.MINUTE, 0) + + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateUnlockAt() + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowDatePicker) + (event as UpdateFileEvent.ShowDatePicker).callback( + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH) + ) + + assertEquals(dateFormat.format(calendar.time), viewModel.data.value?.unlockAtDateString) + assertEquals(timeFormat.format(calendar.time), viewModel.data.value?.unlockAtTimeString) + } + + @Test + fun `Update unlock at time`() { + val calendar = Calendar.getInstance() + + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = + ModuleContentDetails(hidden = false, locked = false, unlockAt = calendar.time.toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateUnlockTime() + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowTimePicker) + + calendar.add(Calendar.HOUR_OF_DAY, 1) + calendar.add(Calendar.MINUTE, 1) + (event as UpdateFileEvent.ShowTimePicker).callback( + calendar.get(Calendar.HOUR_OF_DAY), + calendar.get(Calendar.MINUTE) + ) + + assertEquals(dateFormat.format(calendar.time), viewModel.data.value?.unlockAtDateString) + assertEquals(timeFormat.format(calendar.time), viewModel.data.value?.unlockAtTimeString) + } + + @Test + fun `Clear lock time`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, lockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.clearLockDate() + + assertNull(viewModel.data.value?.lockAt) + assertNull(viewModel.data.value?.lockAtDateString) + assertNull(viewModel.data.value?.lockAtTimeString) + } + + @Test + fun `Clear unlock time`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.clearUnlockDate() + + assertNull(viewModel.data.value?.unlockAt) + assertNull(viewModel.data.value?.unlockAtDateString) + assertNull(viewModel.data.value?.unlockAtTimeString) + } + + @Test + fun `Hiding file calls api with correct params`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + coEvery { fileApi.updateFile(any(), any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.HIDDEN) + + viewModel.update() + + coVerify { + fileApi.updateFile( + 1L, + UpdateFileFolder( + hidden = true, + unlockAt = "", + lockAt = "", + locked = false, + visibilityLevel = FileVisibility.PUBLIC.name.lowercase() + ), + any() + ) + } + } + + @Test + fun `Unpublishing file calls api with correct params`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + coEvery { fileApi.updateFile(any(), any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.UNPUBLISHED) + + viewModel.update() + + coVerify { + fileApi.updateFile( + 1L, + UpdateFileFolder( + hidden = false, + unlockAt = "", + lockAt = "", + locked = true, + visibilityLevel = FileVisibility.PUBLIC.name.lowercase() + ), + any() + ) + } + } + + @Test + fun `Publishing file calls api with correct params`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + coEvery { fileApi.updateFile(any(), any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = true, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.PUBLISHED) + + viewModel.update() + + coVerify { + fileApi.updateFile( + 1L, + UpdateFileFolder( + hidden = false, + unlockAt = "", + lockAt = "", + locked = false, + visibilityLevel = FileVisibility.CONTEXT.name.lowercase() + ), + any() + ) + } + } + + @Test + fun `Updating file emits correct events`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + coEvery { fileApi.updateFile(any(), any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = true, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.PUBLISHED) + + viewModel.update() + + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.Close) + verify { + eventBus.post(any()) + } + } + + @Test + fun `Update error sets correct view state`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + coEvery { fileApi.updateFile(any(), any(), any()) } returns DataResult.Fail() + + val contentDetails = ModuleContentDetails(hidden = false, locked = true, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.PUBLISHED) + + viewModel.update() + + assert(viewModel.state.value is ViewState.Error) + } + + @Test + fun `minDate is set if unlockDate is not null`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = true, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateLockAt() + + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowDatePicker) + assertEquals(contentDetails.unlockDate, (event as UpdateFileEvent.ShowDatePicker).minDate) + } + + @Test + fun `maxDate is set if lockDate is not null`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = true, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateUnlockAt() + + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowDatePicker) + assertEquals(contentDetails.lockDate, (event as UpdateFileEvent.ShowDatePicker).maxDate) + } + + private fun createViewModel() = UpdateFileViewModel(savedStateHandle, context, fileApi, eventBus) +} \ No newline at end of file diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentGroupsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentGroupsApi.kt index 6bc4513023..82f37c068a 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentGroupsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentGroupsApi.kt @@ -34,7 +34,7 @@ object AssignmentGroupsApi { private fun assignmentGroupsService(token: String): AssignmentGroupsService = CanvasNetworkAdapter.retrofitWithToken(token).create(AssignmentGroupsService::class.java) - fun createAssignmentGroup(token: String, courseId: Long, name: String, position: Int?, groupWeight: Int?, sisSourceId: Long?): AssignmentGroupApiModel { + fun createAssignmentGroup(token: String, courseId: Long, name: String, position: Int? = null, groupWeight: Int? = null, sisSourceId: Long? = null): AssignmentGroupApiModel { val assignmentGroup = CreateAssignmentGroup(name, position, groupWeight, sisSourceId) return assignmentGroupsService(token) .createAssignmentGroup(courseId, assignmentGroup) diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentsApi.kt index 15b030191b..b665471602 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentsApi.kt @@ -39,23 +39,24 @@ object AssignmentsApi { = CanvasNetworkAdapter.retrofitWithToken(token).create(AssignmentsService::class.java) data class CreateAssignmentRequest( - val courseId : Long, - val withDescription: Boolean = false, - val lockAt: String = "", - val unlockAt: String = "", - val dueAt: String = "", - val submissionTypes: List, - val gradingType: GradingType = GradingType.POINTS, - val allowedExtensions: List? = null, - val teacherToken: String, - val groupCategoryId: Long? = null, - val pointsPossible: Double? = null, - val importantDate: Boolean? = null, - val assignmentGroupId: Long? = null) + val courseId: Long, + val withDescription: Boolean = false, + val lockAt: String = "", + val unlockAt: String = "", + val dueAt: String = "", + val submissionTypes: List, + val gradingType: GradingType = GradingType.POINTS, + val allowedExtensions: List? = null, + val teacherToken: String, + val groupCategoryId: Long? = null, + val pointsPossible: Double? = null, + val importantDate: Boolean? = null, + val assignmentGroupId: Long? = null) fun createAssignment(request: CreateAssignmentRequest): AssignmentApiModel { return createAssignment( request.courseId, + request.teacherToken, request.withDescription, request.lockAt, request.unlockAt, @@ -63,7 +64,6 @@ object AssignmentsApi { request.submissionTypes, request.gradingType, request.allowedExtensions, - request.teacherToken, request.groupCategoryId, request.pointsPossible, request.importantDate, @@ -72,19 +72,19 @@ object AssignmentsApi { } fun createAssignment( - courseId: Long, - withDescription: Boolean, - lockAt: String, - unlockAt: String, - dueAt: String, - submissionTypes: List, - gradingType: GradingType, - allowedExtensions: List?, - teacherToken: String, - groupCategoryId: Long?, - pointsPossible: Double?, - importantDate: Boolean?, - assignmentGroupId: Long?): AssignmentApiModel { + courseId: Long, + teacherToken: String, + withDescription: Boolean = false, + lockAt: String = "", + unlockAt: String = "", + dueAt: String = "", + submissionTypes: List = emptyList(), + gradingType: GradingType = GradingType.POINTS, + allowedExtensions: List? = null, + groupCategoryId: Long? = null, + pointsPossible: Double? = null, + importantDate: Boolean? = null, + assignmentGroupId: Long? = null): AssignmentApiModel { val assignment = CreateAssignmentWrapper(Randomizer.randomAssignment( withDescription, lockAt, diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConferencesApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConferencesApi.kt index 3359cf5004..d00fa68907 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConferencesApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConferencesApi.kt @@ -4,6 +4,7 @@ import com.instructure.dataseeding.model.ConferencesRequestApiModel import com.instructure.dataseeding.model.ConferencesResponseApiModel import com.instructure.dataseeding.model.WebConferenceWrapper import com.instructure.dataseeding.util.CanvasNetworkAdapter +import com.instructure.dataseeding.util.Randomizer import retrofit2.Call import retrofit2.http.Body import retrofit2.http.POST @@ -19,14 +20,14 @@ object ConferencesApi { private fun conferencesService(token: String): ConferencesService = CanvasNetworkAdapter.retrofitWithToken(token).create(ConferencesService::class.java) - fun createCourseConference(token: String, title: String, description: String, conferenceType: String, longRunning: Boolean, duration: Int, userIds: List, courseId: Long): ConferencesResponseApiModel { + fun createCourseConference(courseId: Long, token: String, title: String = Randomizer.randomConferenceTitle(), description: String = Randomizer.randomConferenceDescription(), conferenceType: String = "BigBlueButton", longRunning: Boolean = false, duration: Int = 70, recipientUserIds: List): ConferencesResponseApiModel { val conference = WebConferenceWrapper(webConference = ConferencesRequestApiModel( title, description, conferenceType, longRunning, duration, - userIds) + recipientUserIds) ) return conferencesService(token).createCourseConference(courseId, conference).execute().body()!! diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConversationsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConversationsApi.kt index fc2989bd96..5885fa8caf 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConversationsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConversationsApi.kt @@ -44,7 +44,7 @@ object ConversationsApi { } private fun conversationsService(token: String): ConversationsService - = CanvasNetworkAdapter.retrofitWithToken(token).create(ConversationsApi.ConversationsService::class.java) + = CanvasNetworkAdapter.retrofitWithToken(token).create(ConversationsService::class.java) fun createConversation(token: String, recipients: List, subject: String = Randomizer.randomConversationSubject(), body: String = Randomizer.randomConversationBody()): List { val conversation = CreateConversation(recipients, subject, body) diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/EnrollmentsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/EnrollmentsApi.kt index 1365fc0705..c2dc5eb811 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/EnrollmentsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/EnrollmentsApi.kt @@ -57,7 +57,7 @@ object EnrollmentsApi { = enrollUser(courseId, userId, TA_ENROLLMENT) fun enrollUserAsObserver(courseId: Long, userId: Long, associatedUserId: Long): EnrollmentApiModel - = enrollUser(courseId, userId, OBSERVER_ENROLLMENT, associatedUserId.takeIf { it > 0 }) + = enrollUser(courseId, userId, OBSERVER_ENROLLMENT, associatedUserId = associatedUserId.takeIf { it > 0 }) fun enrollUserAsDesigner(courseId: Long, userId: Long): EnrollmentApiModel = enrollUser(courseId, userId, DESIGNER_ENROLLMENT) @@ -66,8 +66,8 @@ object EnrollmentsApi { courseId: Long, userId: Long, enrollmentType: String, - associatedUserId: Long? = null, - enrollmentService: EnrollmentsService = enrollmentsService + enrollmentService: EnrollmentsService = enrollmentsService, + associatedUserId: Long? = null ): EnrollmentApiModel { val enrollment = EnrollmentApiRequestModel(userId, enrollmentType, enrollmentType, associatedUserId = associatedUserId) return enrollmentService diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileFolderApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileFolderApi.kt new file mode 100644 index 0000000000..5d3d1663bd --- /dev/null +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileFolderApi.kt @@ -0,0 +1,67 @@ +// +// Copyright (C) 2024-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.dataseeding.api + +import com.instructure.dataseeding.model.CourseFolderUploadApiModel +import com.instructure.dataseeding.model.CourseFolderUploadApiRequestModel +import com.instructure.dataseeding.model.CourseRootFolderApiModel +import com.instructure.dataseeding.util.CanvasNetworkAdapter +import retrofit2.Call +import retrofit2.http.* + +object FileFolderApi { + interface FileFolderService { + + @GET("courses/{courseId}/folders/root") + fun getCourseRootFolder( + @Path("courseId") courseId: Long): Call + + @POST("folders/{folderId}/folders") + fun createCourseFolder( + @Path("folderId") folderId: Long, + @Body courseFolderUploadApiRequestModel: CourseFolderUploadApiRequestModel): Call + + } + + private fun fileFolderService(token: String): FileFolderService + = CanvasNetworkAdapter.retrofitWithToken(token).create(FileFolderService::class.java) + + fun getCourseRootFolder(courseId: Long, token: String): CourseRootFolderApiModel { + return fileFolderService(token) + .getCourseRootFolder(courseId = courseId) + .execute() + .body()!! + } + + fun createCourseFolder( + folderId: Long, + token: String, + name: String, + locked: Boolean = false + ): CourseFolderUploadApiModel { + val courseFolderUploadRequestModel = CourseFolderUploadApiRequestModel( + name = name, + locked = locked + ) + return fileFolderService(token) + .createCourseFolder(folderId, courseFolderUploadRequestModel) + .execute() + .body()!! + } + +} diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileUploadsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileUploadsApi.kt index 7c967d8394..ef7404cfc2 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileUploadsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileUploadsApi.kt @@ -44,6 +44,11 @@ object FileUploadsApi { @Path("assignmentId") assignmentId: Long, @Body startFileUpload: StartFileUpload): Call + @POST("folders/{folderId}/files") + fun courseFolderFileUpload( + @Path("folderId") folderId: Long?, + @Body startFileUpload: StartFileUpload): Call + @Multipart @POST fun uploadFile( @@ -66,14 +71,14 @@ object FileUploadsApi { * Start here to upload a file! */ fun uploadFile(courseId: Long, - assignmentId: Long, + assignmentId: Long? = null, file: ByteArray, fileName: String, token: String, fileUploadType: FileUploadType): AttachmentApiModel { // Get file upload params from Canvas, telling us where to upload the file - val params = getFileUploadParams(courseId, assignmentId, file, fileName, fileUploadType, token) + val params = getFileUploadParams(courseId = courseId, assignmentId= assignmentId, file = file, fileName = fileName, fileUploadType = fileUploadType, token = token) // Upload the file based on the params we got back; return the resulting attachment return uploadFileToCanvas(params, file) @@ -94,7 +99,7 @@ object FileUploadsApi { } private fun getFileUploadParams(courseId: Long, - assignmentId: Long, + assignmentId: Long? = null, file: ByteArray, fileName: String, fileUploadType: FileUploadType, @@ -103,8 +108,9 @@ object FileUploadsApi { val request = StartFileUpload(fileName, file.size.toLong()) return when (fileUploadType) { - FileUploadType.ASSIGNMENT_SUBMISSION -> fileUploadsService(token).assignmentSubmissionFileUpload(courseId, assignmentId, request) - FileUploadType.COMMENT_ATTACHMENT -> fileUploadsService(token).commentAttachmentFileUpload(courseId, assignmentId, request) + FileUploadType.ASSIGNMENT_SUBMISSION -> fileUploadsService(token).assignmentSubmissionFileUpload(courseId, assignmentId!!, request) + FileUploadType.COMMENT_ATTACHMENT -> fileUploadsService(token).commentAttachmentFileUpload(courseId, assignmentId!!, request) + FileUploadType.COURSE_FILE -> fileUploadsService(token).courseFolderFileUpload(courseId, request) else -> TODO() }.execute().body()!! } diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ModulesApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ModulesApi.kt index ac428b1ebc..80673ea634 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ModulesApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ModulesApi.kt @@ -34,28 +34,28 @@ object ModulesApi { private fun modulesService(token: String): ModulesService = CanvasNetworkAdapter.retrofitWithToken(token).create(ModulesService::class.java) - fun createModule(courseId: Long, teacherToken: String, unlockAt: String?): ModuleApiModel { + fun createModule(courseId: Long, teacherToken: String, unlockAt: String? = null): ModuleApiModel { val module = CreateModuleWrapper(Randomizer.createModule(unlockAt)) return modulesService(teacherToken).createModules(courseId, module).execute().body()!! } // Canvas API does not support creating a published module. // All modules must be created and published in separate calls. - fun updateModule(courseId: Long, id: Long, published: Boolean, teacherToken: String): ModuleApiModel { + fun updateModule(courseId: Long, teacherToken: String, moduleId: Long, published: Boolean = true): ModuleApiModel { val update = UpdateModuleWrapper(UpdateModule(published)) - return modulesService(teacherToken).updateModule(courseId, id, update).execute().body()!! + return modulesService(teacherToken).updateModule(courseId, moduleId, update).execute().body()!! } fun createModuleItem( - courseId: Long, - moduleId: Long, - teacherToken: String, - title: String, - type: String, - contentId: String?, - pageUrl: String? = null + courseId: Long, + teacherToken: String, + moduleId: Long, + moduleItemTitle: String, + moduleItemType: String, + contentId: String? = null, + pageUrl: String? = null ): ModuleItemApiModel { - val newItem = CreateModuleItem(title, type, contentId, pageUrl) + val newItem = CreateModuleItem(moduleItemTitle, moduleItemType, contentId, pageUrl) val newItemWrapper = CreateModuleItemWrapper(moduleItem = newItem) return modulesService(teacherToken).createModuleItem( courseId = courseId, 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 32e480f9e4..5c5353a4a0 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 @@ -39,12 +39,12 @@ object PagesApi { fun createCoursePage( courseId: Long, - published: Boolean, - frontPage: Boolean, - editingRoles: String? = null, token: String, - body: String = Randomizer.randomPageBody() - ): PageApiModel { + published: Boolean = true, + frontPage: Boolean = false, + body: String = Randomizer.randomPageBody(), + editingRoles: String? = null, + ): PageApiModel { val page = CreatePageWrapper(CreatePage(Randomizer.randomPageTitle(), body, published, frontPage, editingRoles)) return pagesService(token) diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/QuizzesApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/QuizzesApi.kt index 91c0c36af9..13b575ed2d 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/QuizzesApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/QuizzesApi.kt @@ -80,12 +80,13 @@ object QuizzesApi { fun createQuiz( courseId: Long, - withDescription: Boolean, - lockAt: String, - unlockAt: String, - dueAt: String, - published: Boolean, - token: String): QuizApiModel { + token: String, + withDescription: Boolean = true, + lockAt: String = "", + unlockAt: String = "", + dueAt: String = "", + published: Boolean = true + ): QuizApiModel { val quiz = CreateQuiz(Randomizer.randomQuiz(withDescription, lockAt, unlockAt, dueAt, published)) return quizzesService(token) @@ -178,7 +179,7 @@ object QuizzesApi { val result = QuizListApiModel( quizList = (0 until numQuizzes).map { - QuizzesApi.createQuiz(request) + createQuiz(request) } ) @@ -208,7 +209,7 @@ object QuizzesApi { // Convenience method to create and publish a quiz with questions fun createAndPublishQuiz(courseId: Long, teacherToken: String, questions: List) : QuizApiModel { - val result = QuizzesApi.createQuiz(QuizzesApi.CreateQuizRequest( + val result = createQuiz(CreateQuizRequest( courseId = courseId, withDescription = true, published = false, // Will publish in just a bit, after we add questions @@ -216,7 +217,7 @@ object QuizzesApi { )) for(question in questions) { - val result = QuizzesApi.createQuizQuestion( + val result = createQuizQuestion( courseId = courseId, quizId = result.id, teacherToken = teacherToken, @@ -225,7 +226,7 @@ object QuizzesApi { question.id = result.id // back-fill the question id } - QuizzesApi.publishQuiz( + publishQuiz( courseId = courseId, quizId = result.id, teacherToken = teacherToken, diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt index ca8fe7fac6..4f11a7a1e7 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt @@ -59,11 +59,12 @@ object SubmissionsApi { private fun submissionsService(token: String): SubmissionsService = CanvasNetworkAdapter.retrofitWithToken(token).create(SubmissionsService::class.java) - fun submitCourseAssignment(submissionType: SubmissionType, - courseId: Long, + fun submitCourseAssignment(courseId: Long, + studentToken: String, assignmentId: Long, - fileIds: MutableList, - studentToken: String): SubmissionApiModel { + submissionType: SubmissionType, + fileIds: MutableList = mutableListOf() + ): SubmissionApiModel { val submission = Randomizer.randomSubmission(submissionType, fileIds) @@ -73,10 +74,10 @@ object SubmissionsApi { .body()!! } - fun commentOnSubmission(studentToken: String, - courseId: Long, + fun commentOnSubmission(courseId: Long, + studentToken: String, assignmentId: Long, - fileIds: MutableList, + fileIds: MutableList = mutableListOf(), attempt: Int = 1): AssignmentApiModel { val comment = Randomizer.randomSubmissionComment(fileIds, attempt) @@ -101,8 +102,8 @@ object SubmissionsApi { courseId: Long, assignmentId: Long, studentId: Long, - postedGrade: String? = null, - excused: Boolean): SubmissionApiModel { + excused: Boolean = false, + postedGrade: String? = null): SubmissionApiModel { return submissionsService(teacherToken) .gradeSubmission(courseId, assignmentId, studentId, GradeSubmissionWrapper(GradeSubmission(postedGrade, excused))) @@ -144,70 +145,69 @@ object SubmissionsApi { /** Seed one or more submissions for an assignment. Accepts a SubmissionSeedRequest, returns a * list of SubmissionApiModel objects. */ - fun seedAssignmentSubmission(request: SubmissionSeedRequest) : List { + fun seedAssignmentSubmission(courseId: Long, studentToken: String, assignmentId: Long, commentSeedsList: List = listOf(), submissionSeedsList: List = listOf()): List { val submissionsList = mutableListOf() - with(request) { - for (seed in submissionSeedsList) { - for (t in 0 until seed.amount) { - - // Submit an assignment - - // Canvas will only record submissions with unique "submitted_at" values. - // Sleep for 1 second to ensure submissions are recorded!!! - // - // https://github.com/instructure/mobile_qa/blob/7f985a08161f457e9b5d60987bd6278d21e2557e/SoSeedy/lib/so_seedy/canvas_models/account_admin.rb#L357-L359 - Thread.sleep(1000) - var submission = submitCourseAssignment( - submissionType = seed.submissionType, - courseId = courseId, - assignmentId = assignmentId, - fileIds = seed.attachmentsList.map { it.id }.toMutableList(), - studentToken = studentToken - ) - - if (seed.checkForLateStatus) { - val maxAttempts = 6 - var attempts = 1 - while (attempts < maxAttempts) { - val submissionResponse = getSubmission ( + + for (seed in submissionSeedsList) { + for (t in 0 until seed.amount) { + + // Submit an assignment + + // Canvas will only record submissions with unique "submitted_at" values. + // Sleep for 1 second to ensure submissions are recorded!!! + // + // https://github.com/instructure/mobile_qa/blob/7f985a08161f457e9b5d60987bd6278d21e2557e/SoSeedy/lib/so_seedy/canvas_models/account_admin.rb#L357-L359 + Thread.sleep(1000) + var submission = submitCourseAssignment( + submissionType = seed.submissionType, + courseId = courseId, + assignmentId = assignmentId, + fileIds = seed.attachmentsList.map { it.id }.toMutableList(), + studentToken = studentToken + ) + + if (seed.checkForLateStatus) { + val maxAttempts = 6 + var attempts = 1 + while (attempts < maxAttempts) { + val submissionResponse = getSubmission ( + studentToken = studentToken, + courseId = courseId, + assignmentId = assignmentId, + studentId = submission.userId + ) + if (submissionResponse.late) break + RetryBackoff.wait(attempts) + attempts++ + } + } + + // Create comments on the submitted assignment + submission = commentSeedsList + .map { + // Create comments with any assigned upload file types + val assignment = commentOnSubmission( studentToken = studentToken, courseId = courseId, assignmentId = assignmentId, - studentId = submission.userId + fileIds = it.attachmentsList.filter { it.id != -1L }.map { it.id }.toMutableList()) + + // Apparently, we only care about id and submissionComments + SubmissionApiModel( + id = assignment.id, + submissionComments = assignment.submissionComments!!, + url = null, + body = null, + userId = 0, + grade = null, + attempt = assignment.attempt!! + ) - if (submissionResponse.late) break - RetryBackoff.wait(attempts) - attempts++ } - } + .lastOrNull() ?: submission // Last one (if it exists) will have all the comments loaded up on it - // Create comments on the submitted assignment - submission = commentSeedsList - .map { - // Create comments with any assigned upload file types - val assignment = commentOnSubmission( - studentToken = studentToken, - courseId = courseId, - assignmentId = assignmentId, - fileIds = it.attachmentsList.filter { it.id != -1L }.map { it.id }.toMutableList()) - - // Apparently, we only care about id and submissionComments - SubmissionApiModel( - id = assignment.id, - submissionComments = assignment.submissionComments!!, - url = null, - body = null, - userId = 0, - grade = null, - attempt = assignment.attempt!! - - ) - } - .lastOrNull() ?: submission // Last one (if it exists) will have all the comments loaded up on it - - // Add submission to our collection - submissionsList.add(submission) - } + // Add submission to our collection + submissionsList.add(submission) } } diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/UserApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/UserApi.kt index 4ad56323f6..320922357e 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/UserApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/UserApi.kt @@ -63,7 +63,7 @@ object UserApi { userAdminService.putSelfSettings(userId, requestApiModel).execute() } - fun createCanvasUser( + fun createCanvasUser( userService: UserService = userAdminService, userDomain: String = CanvasNetworkAdapter.canvasDomain ): CanvasUserApiModel { diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseFolderUploadApiModel.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseFolderUploadApiModel.kt new file mode 100644 index 0000000000..456a94b3d4 --- /dev/null +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseFolderUploadApiModel.kt @@ -0,0 +1,39 @@ +// +// Copyright (C) 2024-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.dataseeding.model + +import com.google.gson.annotations.SerializedName + +data class CourseFolderUploadApiRequestModel( + @SerializedName("name") + val name: String, + @SerializedName("locked") + val locked: Boolean = false +) + +data class CourseFolderUploadApiModel( + @SerializedName("id") + val id: Long, + @SerializedName("context_type") + val contextType: String, + @SerializedName("parent_folder_id") + val parentFolderId: Long, + @SerializedName("name") + val name: String, + @SerializedName("locked") + val locked: Boolean = false +) \ No newline at end of file diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseRootFolderApiModel.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseRootFolderApiModel.kt new file mode 100644 index 0000000000..13aa27a0c6 --- /dev/null +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseRootFolderApiModel.kt @@ -0,0 +1,30 @@ +// +// Copyright (C) 2024-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.dataseeding.model + +import com.google.gson.annotations.SerializedName + +data class CourseRootFolderApiModel( + @SerializedName("id") + val id: Long, + @SerializedName("context_type") + val contextType: String, + @SerializedName("context_id") + val contextId: Long, + @SerializedName("name") + val name: String +) \ No newline at end of file diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/DiscussionApiModel.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/DiscussionApiModel.kt index ccfbae6996..8bc6092a40 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/DiscussionApiModel.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/DiscussionApiModel.kt @@ -28,7 +28,9 @@ data class DiscussionApiModel ( @SerializedName("locked_for_user") val lockedForUser: Boolean, @SerializedName("locked") - val locked: Boolean + val locked: Boolean, + @SerializedName("published") + val published: Boolean? = true ) data class CreateDiscussionTopic( diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/FileUploadType.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/FileUploadType.kt index eecbb26889..2d348251c7 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/FileUploadType.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/FileUploadType.kt @@ -17,5 +17,6 @@ package com.instructure.dataseeding.model enum class FileUploadType { ASSIGNMENT_SUBMISSION, - COMMENT_ATTACHMENT + COMMENT_ATTACHMENT, + COURSE_FILE } diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionType.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionType.kt index ed428d9257..dd640e2ece 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionType.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionType.kt @@ -23,5 +23,6 @@ enum class SubmissionType { ONLINE_TEXT_ENTRY, ONLINE_URL, EXTERNAL_TOOL, - DISCUSSION_TOPIC + DISCUSSION_TOPIC, + MEDIA_RECORDING } diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/util/Randomizer.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/util/Randomizer.kt index 32e38aa4cb..85a536e81d 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/util/Randomizer.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/util/Randomizer.kt @@ -18,7 +18,15 @@ package com.instructure.dataseeding.util import com.github.javafaker.Faker -import com.instructure.dataseeding.model.* +import com.instructure.dataseeding.model.CreateAssignment +import com.instructure.dataseeding.model.CreateDiscussionTopic +import com.instructure.dataseeding.model.CreateGroup +import com.instructure.dataseeding.model.CreateModule +import com.instructure.dataseeding.model.CreateQuiz +import com.instructure.dataseeding.model.CreateSubmissionComment +import com.instructure.dataseeding.model.GradingType +import com.instructure.dataseeding.model.SubmissionType +import com.instructure.dataseeding.model.SubmitCourseAssignmentSubmission import java.util.* object Randomizer { @@ -60,6 +68,10 @@ object Randomizer { fun randomConversationSubject(): String = faker.chuckNorris().fact() fun randomConversationBody(): String = faker.lorem().paragraph() + fun randomConferenceTitle(): String = faker.chuckNorris().fact() + fun randomConferenceDescription(): String = faker.lorem().paragraph() + + fun randomEnrollmentTitle(): String = "${faker.pokemon()} Term" fun randomGradingPeriodSetTitle(): String = "${faker.pokemon().location()} Set" @@ -133,6 +145,8 @@ object Randomizer { /** Creates a random Course Group Category name */ fun randomCourseGroupCategoryName(): String = faker.harryPotter().character() + fun randomModuleName(): String = faker.lordOfTheRings().location() + /** Creates random Group */ fun randomGroup() = CreateGroup(name = faker.harryPotter().location(), description = faker.harryPotter().quote()) diff --git a/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/AssignmentsTest.kt b/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/AssignmentsTest.kt index 89eb012d4e..35d43b88d0 100644 --- a/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/AssignmentsTest.kt +++ b/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/AssignmentsTest.kt @@ -28,12 +28,12 @@ import com.instructure.dataseeding.api.UserApi import com.instructure.dataseeding.model.AssignmentApiModel import com.instructure.dataseeding.model.AssignmentOverrideApiModel import com.instructure.dataseeding.model.AttachmentApiModel +import com.instructure.dataseeding.model.FileType +import com.instructure.dataseeding.model.GradingType.PERCENT import com.instructure.dataseeding.model.SubmissionApiModel import com.instructure.dataseeding.model.SubmissionCommentApiModel -import com.instructure.dataseeding.model.FileType -import com.instructure.dataseeding.model.SubmissionType.ONLINE_UPLOAD import com.instructure.dataseeding.model.SubmissionType.ONLINE_TEXT_ENTRY -import com.instructure.dataseeding.model.GradingType.PERCENT +import com.instructure.dataseeding.model.SubmissionType.ONLINE_UPLOAD import org.hamcrest.CoreMatchers.instanceOf import org.junit.Assert.* import org.junit.Before @@ -112,7 +112,7 @@ class AssignmentsTest { submissionTypes = listOf(ONLINE_TEXT_ENTRY), teacherToken = teacher.token )) - val submission = SubmissionsApi.commentOnSubmission(student.token,course.id,assignment.id,ArrayList()) + val submission = SubmissionsApi.commentOnSubmission(course.id,student.token,assignment.id,ArrayList()) assertThat(submission, instanceOf(AssignmentApiModel::class.java)) assertEquals(1, submission.submissionComments?.size ?: 0) val comment = submission.submissionComments?.get(0) @@ -283,7 +283,7 @@ class AssignmentsTest { studentToken = student.token, submissionSeedsList = listOf(submissionSeed) ) - val submissions = SubmissionsApi.seedAssignmentSubmission( request ) + val submissions = SubmissionsApi.seedAssignmentSubmission( course.id, student.token, assignment.id, submissionSeedsList = listOf(submissionSeed)) if(submissions.isNotEmpty()) { assertThat(submissions[0], instanceOf(SubmissionApiModel::class.java)) } diff --git a/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/ModulesTest.kt b/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/ModulesTest.kt index 02fd032053..31b0aeb4d6 100644 --- a/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/ModulesTest.kt +++ b/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/ModulesTest.kt @@ -6,7 +6,9 @@ import com.instructure.dataseeding.api.ModulesApi import com.instructure.dataseeding.api.UserApi import com.instructure.dataseeding.model.ModuleApiModel import org.hamcrest.CoreMatchers.instanceOf -import org.junit.Assert.* +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThat +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -57,7 +59,7 @@ class ModulesTest { module = ModulesApi.updateModule( courseId = course.id, - id = module.id, + moduleId = module.id, published = true, teacherToken = teacher.token ) 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 adf8d1f501..f55dbbb6b8 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 @@ -80,7 +80,7 @@ abstract class CanvasTest : InstructureTestingContract { val connectivityManager = InstrumentationRegistry.getInstrumentation().context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - @Rule(order = 1) + @Rule(order = 2) override fun chain(): TestRule { return RuleChain .outerRule(ScreenshotTestRule()) diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/StubCoverageAnnotation.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/StubCoverageAnnotation.kt new file mode 100644 index 0000000000..66d7c43636 --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/StubCoverageAnnotation.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2024 - 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.canvas.espresso + +// When applied to a test method, denotes that the test is stubbed out from the coverage workflow. +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class StubCoverage(val description: String = "") \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt index 975a98505f..045d36932b 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt @@ -33,16 +33,16 @@ enum class Priority { enum class FeatureCategory { ASSIGNMENTS, SUBMISSIONS, LOGIN, COURSE, DASHBOARD, GROUPS, SETTINGS, PAGES, DISCUSSIONS, MODULES, INBOX, GRADES, FILES, EVENTS, PEOPLE, CONFERENCES, COLLABORATIONS, SYLLABUS, TODOS, QUIZZES, NOTIFICATIONS, - ANNOTATIONS, ANNOUNCEMENTS, COMMENTS, BOOKMARKS, NONE, K5_DASHBOARD, SPEED_GRADER, SYNC_SETTINGS, SYNC_PROGRESS, OFFLINE_CONTENT, LEFT_SIDE_MENU + ANNOTATIONS, ANNOUNCEMENTS, COMMENTS, BOOKMARKS, NONE, CANVAS_FOR_ELEMENTARY, SPEED_GRADER, SYNC_SETTINGS, SYNC_PROGRESS, OFFLINE_CONTENT, LEFT_SIDE_MENU } enum class SecondaryFeatureCategory { NONE, LOGIN_K5, - SUBMISSIONS_TEXT_ENTRY, SUBMISSIONS_ANNOTATIONS, SUBMISSIONS_ONLINE_URL, - ASSIGNMENT_COMMENTS, ASSIGNMENT_QUIZZES, ASSIGNMENT_DISCUSSIONS, + SUBMISSIONS_TEXT_ENTRY, SUBMISSIONS_ANNOTATIONS, SUBMISSIONS_ONLINE_URL, SUBMISSIONS_MULTIPLE_TYPE, + ASSIGNMENT_COMMENTS, ASSIGNMENT_QUIZZES, ASSIGNMENT_DISCUSSIONS, HOMEROOM, K5_GRADES, IMPORTANT_DATES, RESOURCES, SCHEDULE, GROUPS_DASHBOARD, GROUPS_FILES, GROUPS_ANNOUNCEMENTS, GROUPS_DISCUSSIONS, GROUPS_PAGES, GROUPS_PEOPLE, EVENTS_DISCUSSIONS, EVENTS_QUIZZES, EVENTS_ASSIGNMENTS, EVENTS_NOTIFICATIONS, - MODULES_ASSIGNMENTS, MODULES_DISCUSSIONS, MODULES_FILES, MODULES_PAGES, MODULES_QUIZZES, OFFLINE_MODE, ALL_COURSES, CHANGE_USER + MODULES_ASSIGNMENTS, MODULES_DISCUSSIONS, MODULES_FILES, MODULES_PAGES, MODULES_QUIZZES, OFFLINE_MODE, ALL_COURSES, CHANGE_USER, ASSIGNMENT_REMINDER } enum class TestCategory { 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 9b559020bb..597435a46b 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 @@ -375,7 +375,7 @@ fun MockCanvas.Companion.init( totalStudents = studentUsers.count() ) } - val course = data.addCourse( + data.addCourse( isFavorite = it < favoriteCourseCount, id = courseId, section = section, @@ -827,24 +827,24 @@ fun MockCanvas.addAssignmentsToGroups(course: Course, assignmentCountPerGroup: I for (i in 0 until assignmentCountPerGroup) { - val overdueAssignment = addAssignment( + addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL), name = Randomizer.randomAssignmentName(), dueAt = pastDueDate, assignmentGroupId = overdueAssignmentGroup.id ) - val upcomingAssignment = addAssignment( + addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL), name = Randomizer.randomAssignmentName(), dueAt = futureDueDate, assignmentGroupId = upcomingAssignmentGroup.id ) - val undatedAssignment = addAssignment( + addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL), name = Randomizer.randomAssignmentName(), dueAt = null, assignmentGroupId = undatedAssignmentGroup.id @@ -852,12 +852,12 @@ fun MockCanvas.addAssignmentsToGroups(course: Course, assignmentCountPerGroup: I val pastAssignment = addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL), name = Randomizer.randomAssignmentName(), dueAt = pastDueDate, assignmentGroupId = pastAssignmentGroup.id ) - val pastSubmission = addSubmissionForAssignment( + addSubmissionForAssignment( assignmentId = pastAssignment.id, userId = users.values.first().id, type = Assignment.SubmissionType.ONLINE_URL.apiString, @@ -874,13 +874,13 @@ fun MockCanvas.addAssignmentsToGroups(course: Course, assignmentCountPerGroup: I */ fun MockCanvas.addAssignment( courseId: Long, - submissionType: Assignment.SubmissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList: List = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), assignmentGroupId: Long = newItemId(), isQuizzesNext: Boolean = false, lockInfo : LockInfo? = null, userSubmitted: Boolean = false, dueAt: String? = null, - name: String = Randomizer.randomCourseName(), + name: String = Randomizer.randomAssignmentName(), pointsPossible: Int = 10, description: String = "", lockAt: String? = null, @@ -889,12 +889,13 @@ fun MockCanvas.addAssignment( gradingType: String = "percent" ) : Assignment { val assignmentId = newItemId() + val submissionTypeListRawStrings = submissionTypeList.map { it.apiString } var assignment = Assignment( id = assignmentId, assignmentGroupId = assignmentGroupId, courseId = courseId, name = name, - submissionTypesRaw = listOf(submissionType.apiString), + submissionTypesRaw = submissionTypeListRawStrings, lockInfo = lockInfo, lockedForUser = lockInfo != null, userSubmitted = userSubmitted, @@ -921,7 +922,7 @@ fun MockCanvas.addAssignment( assignmentGroups[courseId] = assignmentGroupList } - var group = assignmentGroupList.find { it.id == assignmentGroupId } + val group = assignmentGroupList.find { it.id == assignmentGroupId } if (group == null) { assignmentGroupList.add(AssignmentGroup(id = assignmentGroupId, assignments = listOf(assignment))) } else { @@ -941,6 +942,45 @@ fun MockCanvas.addAssignment( return assignment } +/** + * Adds multiple (type) submissions to the assignment submission map. + */ +fun MockCanvas.addSubmissionsForAssignment( + assignmentId: Long, + userId: Long, + types: List, + body: String? = null, + url: String? = null, + attachment: Attachment? = null, + comment: SubmissionComment? = null, + state: String = "submitted", + grade: String? = null, + attempt: Long = 1, + score: Double? = null, + excused: Boolean = false): MutableList { + + val submissionList = mutableListOf() + types.forEach { type -> + val submission = addSubmissionForAssignment( + assignmentId, + userId, + type, + body, + url, + attachment, + comment, + state, + grade, + attempt, + score, + excused + ) + submissionList.add(submission) + } + + return submissionList + +} /** * Adds a submission to the assignment submission map. */ @@ -1253,7 +1293,7 @@ fun MockCanvas.addFolderToCourse( groupId: Long? = null ) : Long { // Find your root folder - var rootFolder = getRootFolder(courseId = courseId, groupId = groupId) + val rootFolder = getRootFolder(courseId = courseId, groupId = groupId) // Now create our folder metadata val newFolderId = newItemId() @@ -1298,12 +1338,13 @@ fun MockCanvas.addFileToFolder( fileContent: String = Randomizer.randomPageBody(), contentType: String = "text/plain", url: String = "", - fileId: Long = newItemId() + fileId: Long = newItemId(), + visibilityLevel: String = "inherit" ) : Long { if(courseId == null && folderId == null) { throw Exception("Either courseId or folderId must be non-null") } - var rootFolder = getRootFolder(courseId = courseId, folderId = folderId) + val rootFolder = getRootFolder(courseId = courseId, folderId = folderId) // Now create our file metadata val fileMetadataItem = FileFolder( @@ -1312,7 +1353,8 @@ fun MockCanvas.addFileToFolder( size = fileContent.length.toLong(), displayName = displayName, contentType = contentType, - url = if(url.isEmpty()) "https://mock-data.instructure.com/files/$fileId/preview" else url + url = if(url.isEmpty()) "https://mock-data.instructure.com/files/$fileId/preview" else url, + visibilityLevel = visibilityLevel ) // And record it for the folder @@ -1328,8 +1370,6 @@ fun MockCanvas.addFileToFolder( fileContents[fileId] = fileContent return fileId - - } /** * Creates a new file for the specified course, and records it properly in MockCanvas. @@ -1338,21 +1378,23 @@ fun MockCanvas.addFileToFolder( */ fun MockCanvas.addFileToCourse( courseId: Long, - displayName: String, + displayName: String = Randomizer.randomPageTitle(), fileContent: String = Randomizer.randomPageBody(), contentType: String = "text/plain", groupId: Long? = null, url: String = "", - fileId: Long = newItemId() + fileId: Long = newItemId(), + visibilityLevel: String = "inherit" ): Long { - var rootFolder = getRootFolder(courseId = courseId, groupId = groupId) + val rootFolder = getRootFolder(courseId = courseId, groupId = groupId) return addFileToFolder( folderId = rootFolder.id, displayName = displayName, fileContent = fileContent, contentType = contentType, url = url, - fileId = fileId + fileId = fileId, + visibilityLevel = visibilityLevel ) } @@ -1495,6 +1537,7 @@ fun MockCanvas.addItemToModule( course: Course, moduleId: Long, item: Any, + contentId: Long = 0, published: Boolean = true, moduleContentDetails: ModuleContentDetails? = null ) : ModuleItem { @@ -1559,11 +1602,12 @@ fun MockCanvas.addItemToModule( // htmlUrl populated in order to get external url module items to work. url = itemUrl, htmlUrl = itemUrl, + contentId = contentId, moduleDetails = moduleContentDetails ) // Copy/update/replace the module - var newItemList = module.items.toMutableList() + val newItemList = module.items.toMutableList() newItemList.add(result) val changedModule = module.copy( items = newItemList @@ -1697,7 +1741,7 @@ fun MockCanvas.addQuestionToQuiz( fun MockCanvas.addQuizSubmission(quiz: Quiz, user: User, state: String = "untaken", grade: String? = null) : QuizSubmission { val now = Calendar.getInstance().time.time // ms - var quizSubmission = QuizSubmission( + val quizSubmission = QuizSubmission( id = newItemId(), quizId = quiz.id, userId = user.id, @@ -1710,7 +1754,7 @@ fun MockCanvas.addQuizSubmission(quiz: Quiz, user: User, state: String = "untake // Don't forget to add some sort of submission to the related assignment if(quiz.assignmentId != 0L) { - val assignment = assignments[quiz.assignmentId] + assignments[quiz.assignmentId] this.addSubmissionForAssignment(assignmentId = quiz.assignmentId, userId = user.id, type = "online_quiz", state = state, grade = grade) } diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/ApiEndpoint.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/ApiEndpoint.kt index 222d390b54..a3ed930581 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/ApiEndpoint.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/ApiEndpoint.kt @@ -23,7 +23,9 @@ import com.instructure.canvas.espresso.mockCanvas.addDiscussionTopicToCourse import com.instructure.canvas.espresso.mockCanvas.endpoint import com.instructure.canvas.espresso.mockCanvas.utils.* import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.utils.toDate import okio.Buffer +import retrofit2.http.GET /** * Base endpoint for the Canvas API @@ -153,22 +155,82 @@ object ApiEndpoint : Endpoint( ) { override val authModel = DontCareAuthModel } + ), + Segment("progress") to Endpoint( + LongId(PathVars::progressId) to Endpoint { + GET { + request.successResponse(Progress(pathVars.progressId, workflowState = "completed")) + } + Segment("cancel") to Endpoint { + POST { + request.successResponse(Progress(pathVars.progressId, workflowState = "failed")) + } + } + } ) ) object FileUrlEndpoint : Endpoint( - LongId(PathVars::fileId) to endpoint( - configure = { - GET { - val file = data.folderFiles.values.flatten().find { it.id == pathVars.fileId } - if(file != null) { - request.successResponse(file) - } else { - request.unauthorizedResponse() + LongId(PathVars::fileId) to Endpoint { + GET { + val file = data.folderFiles.values.flatten().find { it.id == pathVars.fileId } + if (file != null) { + request.successResponse(file) + } else { + request.unauthorizedResponse() + } + } + PUT { + val buffer = Buffer() + request.body!!.writeTo(buffer) + val body = buffer.readUtf8() + val updateFileFolder = Gson().fromJson(body, UpdateFileFolder::class.java) + val folderFiles = data.folderFiles.values.find { it.any { it.id == pathVars.fileId } } + val file = data.folderFiles.values.flatten().find { it.id == pathVars.fileId } + if (file == null || folderFiles == null) { + request.unauthorizedResponse() + } else { + val folderId = file.parentFolderId + val updatedFile = file.copy( + lockDate = updateFileFolder.lockAt.toDate() ?: file.lockDate, + unlockDate = updateFileFolder.unlockAt.toDate() ?: file.unlockDate, + isLocked = updateFileFolder.locked ?: file.isLocked, + isHidden = updateFileFolder.hidden ?: file.isHidden, + visibilityLevel = updateFileFolder.visibilityLevel ?: file.visibilityLevel + ) + val updatedFolderFiles = folderFiles.map { if (it.id == pathVars.fileId) updatedFile else it } + data.folderFiles[folderId] = updatedFolderFiles.toMutableList() + + val affectedCourseMap = data.courseModules.filterValues { modules -> + modules.any { module -> + module.items.any { it.contentId == pathVars.fileId } + } + } + + affectedCourseMap.forEach { (courseId, modules) -> + val updatedModules = modules.map { module -> + val updatedItems = module.items.filter { it.contentId == pathVars.fileId } + .associate { moduleItem -> + moduleItem.id to moduleItem.copy( + published = !updatedFile.isLocked, + moduleDetails = ModuleContentDetails( + lockAt = updatedFile.lockDate?.toString(), + unlockAt = updatedFile.unlockDate?.toString(), + locked = updatedFile.isLocked, + hidden = updatedFile.isHidden + ) + ) + } + module.copy(items = module.items.map { moduleItem -> updatedItems[moduleItem.id] ?: moduleItem }) + } + data.courseModules[courseId] = updatedModules.toMutableList() } + + request.successResponse(updatedFile) } + } - ) + } ) object CanvadocRedirectEndpoint : Endpoint( diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/CourseEndpoints.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/CourseEndpoints.kt index a9a0ed63f7..e8a662d622 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/CourseEndpoints.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/CourseEndpoints.kt @@ -21,6 +21,8 @@ import com.google.gson.Gson import com.instructure.canvas.espresso.mockCanvas.* import com.instructure.canvas.espresso.mockCanvas.utils.* import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.models.postmodels.BulkUpdateProgress +import com.instructure.canvasapi2.models.postmodels.BulkUpdateResponse import com.instructure.canvasapi2.models.postmodels.UpdateCourseWrapper import com.instructure.canvasapi2.utils.globalName import com.instructure.canvasapi2.utils.toApiString @@ -817,6 +819,35 @@ object CourseModuleListEndpoint : Endpoint( request.unauthorizedResponse() } } + PUT { + val moduleIds = request.url.queryParameterValues("module_ids[]").filterNotNull().map { it.toLong() } + val event = request.url.queryParameter("event") + val skipContentTags = request.url.queryParameter("skip_content_tags").toBoolean() + + val modules = data.courseModules[pathVars.courseId]?.filter { moduleIds.contains(it.id) } + + val updatedModules = modules?.map { + val updatedItems = if (skipContentTags) { + it.items + } else { + it.items.map { it.copy(published = event == "publish") } + } + it.copy( + published = event == "publish", + items = updatedItems + ) + } + + data.courseModules[pathVars.courseId]?.map { moduleObject -> + updatedModules?.find { updatedModuleObject -> + updatedModuleObject.id == moduleObject.id + } ?: moduleObject + }?.let { + data.courseModules[pathVars.courseId] = it.toMutableList() + } + + request.successResponse(BulkUpdateResponse(BulkUpdateProgress(Progress(1L, workflowState = "running")))) + } } ) @@ -865,7 +896,42 @@ object CourseModuleItemsListEndpoint : Endpoint( } else { request.unauthorizedResponse() } + } + PUT { + val isPublished = request.url.queryParameter("module_item[published]").toBoolean() + val moduleList = data.courseModules[pathVars.courseId] + val moduleObject = moduleList?.find { it.id == pathVars.moduleId } + val itemList = moduleObject?.items + val moduleItem = itemList?.find { it.id == pathVars.moduleItemId } + + if (moduleItem != null) { + val updatedItem = moduleItem.copy(published = isPublished) + + val updatedModule = moduleObject.copy( + items = itemList.map { + if (it.id == updatedItem.id) { + updatedItem + } else { + it + } + } + ) + + data.courseModules[pathVars.courseId]?.map { + if (it.id == updatedModule.id) { + updatedModule + } else { + it + } + }?.let { + data.courseModules[pathVars.courseId] = it.toMutableList() + } + + request.successResponse(updatedItem) + } else { + request.unauthorizedResponse() + } } }, response = { diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/utils/PathUtils.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/utils/PathUtils.kt index ff8beac66c..a46d2247bf 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/utils/PathUtils.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/utils/PathUtils.kt @@ -48,6 +48,7 @@ class PathVars { var annotationid: String by map var bookmarkId: Long by map var enrollmentId: Long by map + var progressId: Long by map } /** diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/ModuleItemInteractions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/ModuleItemInteractions.kt new file mode 100644 index 0000000000..e32d598311 --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/ModuleItemInteractions.kt @@ -0,0 +1,80 @@ +package com.instructure.espresso + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.instructure.espresso.page.plus + +class ModuleItemInteractions(private val moduleNameId: Int? = null, private val nextArrowId: Int? = null, private val previousArrowId: Int? = null) { + + /** + * Assert module name displayed + * + * @param moduleName + */ + fun assertModuleNameDisplayed(moduleName: String) { + onView(moduleNameId?.let { withId(it) + ViewMatchers.withText(moduleName)}).assertDisplayed() + } + + + /** + * Click on next arrow to navigate to the next module item's details + * + */ + fun clickOnNextArrow() { + onView(nextArrowId?.let { withId(it) }).click() + } + + /** + * Click on previous arrow to navigate to the previous module item's details + * + */ + fun clickOnPreviousArrow() { + onView(previousArrowId?.let { withId(it) }).click() + } + + /** + * Assert previous arrow not displayed (e.g. invisible) + * + */ + fun assertPreviousArrowNotDisplayed() { + onView(previousArrowId?.let { withId(it) }).check( + ViewAssertions.matches( + ViewMatchers.withEffectiveVisibility( + ViewMatchers.Visibility.INVISIBLE))) + } + + /** + * Assert previous arrow displayed + * + */ + fun assertPreviousArrowDisplayed() { + onView(previousArrowId?.let { withId(it) }).check( + ViewAssertions.matches( + ViewMatchers.withEffectiveVisibility( + ViewMatchers.Visibility.VISIBLE))) + } + + /** + * Assert next arrow displayed + * + */ + fun assertNextArrowDisplayed() { + onView(nextArrowId?.let { withId(it) }).check( + ViewAssertions.matches( + ViewMatchers.withEffectiveVisibility( + ViewMatchers.Visibility.VISIBLE))) + } + + /** + * Assert next arrow not displayed (e.g. invisible) + * + */ + fun assertNextArrowNotDisplayed() { + onView(nextArrowId?.let { withId(it) }).check( + ViewAssertions.matches( + ViewMatchers.withEffectiveVisibility( + ViewMatchers.Visibility.INVISIBLE))) + } +} \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestRail.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestRail.kt deleted file mode 100644 index d00d894208..0000000000 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestRail.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.instructure.espresso - -import java.lang.annotation.Retention -import java.lang.annotation.RetentionPolicy - -@Retention(RetentionPolicy.RUNTIME) -@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) -annotation class TestRail(val ID: String = "unknown") \ 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 2901820aae..42fe0e5770 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt @@ -91,4 +91,10 @@ fun retryWithIncreasingDelay( } } block() +} + +fun extractInnerTextById(html: String, id: String): String? { + val pattern = "<[^>]*?\\bid=\"$id\"[^>]*?>(.*?)]*?>".toRegex(RegexOption.DOT_MATCHES_ALL) + val matchResult = pattern.find(html) + return matchResult?.groupValues?.getOrNull(1) } \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/ViewInteractionExtensions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/ViewInteractionExtensions.kt index 1e4c2d3ac8..f6e0332e27 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/ViewInteractionExtensions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/ViewInteractionExtensions.kt @@ -154,3 +154,19 @@ fun ViewInteraction.waitForCheck(assertion: ViewAssertion) { } while (System.currentTimeMillis() < endTime) check(assertion) } + +fun ViewInteraction.assertChecked() { + check(ViewAssertions.matches(ViewMatchers.isChecked())) +} + +fun ViewInteraction.assertNotChecked() { + check(ViewAssertions.matches(ViewMatchers.isNotChecked())) +} + +fun ViewInteraction.assertEnabled() { + check(ViewAssertions.matches(ViewMatchers.isEnabled())) +} + +fun ViewInteraction.assertDisabled() { + check(ViewAssertions.matches(Matchers.not(ViewMatchers.isEnabled()))) +} \ No newline at end of file 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 cae3a84948..87860ec791 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 @@ -18,6 +18,7 @@ package com.instructure.espresso.page import android.view.View +import androidx.annotation.PluralsRes import androidx.test.espresso.Espresso import androidx.test.espresso.ViewInteraction import androidx.test.espresso.matcher.ViewMatchers @@ -160,6 +161,11 @@ fun BasePage.getStringFromResource(stringResource: Int, vararg params: Any): Str return targetContext.resources.getString(stringResource, *params) } +fun BasePage.getPluralFromResource(@PluralsRes pluralsResource: Int, quantity: Int, vararg params: Any): String { + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + return targetContext.resources.getQuantityString(pluralsResource, quantity, *params) +} + fun BasePage.callOnClick(matcher: Matcher) = ViewCallOnClick.callOnClick(matcher) fun BasePage.scrollTo(viewId: Int) = BaristaScrollInteractions.safelyScrollTo(viewId) diff --git a/buildSrc/src/main/java/GlobalDependencies.kt b/buildSrc/src/main/java/GlobalDependencies.kt index 287dfe6e02..7b9cbc0fe9 100644 --- a/buildSrc/src/main/java/GlobalDependencies.kt +++ b/buildSrc/src/main/java/GlobalDependencies.kt @@ -18,6 +18,7 @@ object Versions { /* Kotlin */ const val KOTLIN = "1.9.20" const val KOTLIN_COROUTINES = "1.6.4" + const val KOTLIN_COMPOSE_COMPILER_VERSION = "1.5.4" /* Google, Play Services */ const val GOOGLE_SERVICES = "4.3.15" @@ -161,6 +162,16 @@ object Libs { const val ROOM_TEST = "androidx.room:room-testing:${Versions.ROOM}" const val HAMCREST = "org.hamcrest:hamcrest:${Versions.HAMCREST}" + + // Compose + const val COMPOSE_BOM = "androidx.compose:compose-bom:2023.10.01" + const val COMPOSE_MATERIAL = "androidx.compose.material:material" + const val COMPOSE_PREVIEW = "androidx.compose.ui:ui-tooling-preview" + const val COMPOSE_TOOLING = "androidx.compose.ui:ui-tooling" + const val COMPOSE_UI = "androidx.compose.ui:ui-android" + const val COMPOSE_VIEW_MODEL = "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1" + const val COMPOSE_UI_TEST = "androidx.compose.ui:ui-test-junit4:1.5.4" + const val COMPOSE_UI_TEST_MANIFEST = "androidx.compose.ui:ui-test-manifest" } object Plugins { diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt index cf85789cdc..fe61a10dd4 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt @@ -98,6 +98,9 @@ object FileFolderAPI { @PUT("files/{fileId}?include[]=usage_rights") fun updateFile(@Path("fileId") fileId: Long, @Body updateFileFolder: UpdateFileFolder): Call + @PUT("files/{fileId}?include[]=usage_rights") + suspend fun updateFile(@Path("fileId") fileId: Long, @Body updateFileFolder: UpdateFileFolder, @Tag params: RestParams): DataResult + @POST("folders/{folderId}/folders") fun createFolder(@Path("folderId") folderId: Long, @Body newFolderName: CreateFolder): Call @@ -119,6 +122,9 @@ object FileFolderAPI { @GET("files/{fileNumber}?include=avatar") fun getAvatarFileToken(@Path("fileNumber") fileNumber: String): Call + + @GET("files/{fileId}") + suspend fun getFile(@Path("fileId") fileId: Long, @Tag params: RestParams): DataResult } fun getFileFolderFromURL(adapter: RestBuilder, url: String, callback: StatusCallback, params: RestParams) { diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt index 0b7553d3da..04f55a7d98 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt @@ -20,6 +20,7 @@ import com.instructure.canvasapi2.StatusCallback import com.instructure.canvasapi2.builders.RestBuilder import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.models.postmodels.BulkUpdateResponse import com.instructure.canvasapi2.utils.DataResult import okhttp3.ResponseBody import retrofit2.Call @@ -81,6 +82,15 @@ object ModuleAPI { @GET("{contextId}/modules/{moduleId}/items/{itemId}?include[]=content_details") fun getModuleItem(@Path("contextId") contextId: Long, @Path("moduleId") moduleId: Long, @Path("itemId") itemId: Long) : Call + + @GET("{contextType}/{contextId}/modules/{moduleId}/items/{itemId}?include[]=content_details") + suspend fun getModuleItem(@Path("contextType") contextType: String, @Path("contextId") contextId: Long, @Path("moduleId") moduleId: Long, @Path("itemId") itemId: Long, @Tag params: RestParams) : DataResult + + @PUT("{contextType}/{contextId}/modules") + suspend fun bulkUpdateModules(@Path("contextType") contextType: String, @Path("contextId") contextId: Long, @Query("module_ids[]") moduleIds: List, @Query("event") event: String, @Query("skip_content_tags") skipContentTags: Boolean, @Query("async") async: Boolean, @Tag params: RestParams): DataResult + + @PUT("{contextType}/{contextId}/modules/{moduleId}/items/{itemId}") + suspend fun publishModuleItem(@Path("contextType") contextType: String, @Path("contextId") contextId: Long, @Path("moduleId") moduleId: Long, @Path("itemId") itemId: Long, @Query("module_item[published]") publish: Boolean, @Tag params: RestParams): DataResult } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ProgressAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ProgressAPI.kt index 6223cc1541..137d1b463b 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ProgressAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ProgressAPI.kt @@ -32,6 +32,9 @@ object ProgressAPI { @GET("progress/{progressId}") suspend fun getProgress(@Path("progressId") progressId: String, @Tag params: RestParams): DataResult + + @POST("progress/{progressId}/cancel") + suspend fun cancelProgress(@Path("progressId") progressId: String, @Tag params: RestParams): DataResult } fun getProgress(adapter: RestBuilder, params: RestParams, progressId: String, callback: StatusCallback) { diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CreateFolder.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CreateFolder.kt index 159e04fc24..324699cd25 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CreateFolder.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CreateFolder.kt @@ -33,7 +33,9 @@ data class UpdateFileFolder( @SerializedName("parent_folder_id") var parentFolderId: Long? = null, // Used for Files @SerializedName("on_duplicate") - var onDuplicate: String? = null // Used for files - "overwrite" or "rename" + var onDuplicate: String? = null, // Used for files - "overwrite" or "rename" + @SerializedName("visibility_level") + val visibilityLevel: String? = null ) diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/FileFolder.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/FileFolder.kt index d520c2ebb5..c65f9aa223 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/FileFolder.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/FileFolder.kt @@ -27,65 +27,67 @@ import java.util.Locale @Parcelize data class FileFolder( - // Common Attributes - override val id: Long = 0, - @SerializedName("created_at") - val createdDate: Date? = null, - @SerializedName("updated_at") - val updatedDate: Date? = null, - @SerializedName("unlock_at") - var unlockDate: Date? = null, - @SerializedName("lock_at") - var lockDate: Date? = null, - @SerializedName("locked") - var isLocked: Boolean = false, - @SerializedName("hidden") - var isHidden: Boolean = false, - @SerializedName("locked_for_user") - val isLockedForUser: Boolean = false, - @SerializedName("hidden_for_user") - val isHiddenForUser: Boolean = false, + // Common Attributes + override val id: Long = 0, + @SerializedName("created_at") + val createdDate: Date? = null, + @SerializedName("updated_at") + val updatedDate: Date? = null, + @SerializedName("unlock_at") + var unlockDate: Date? = null, + @SerializedName("lock_at") + var lockDate: Date? = null, + @SerializedName("locked") + var isLocked: Boolean = false, + @SerializedName("hidden") + var isHidden: Boolean = false, + @SerializedName("locked_for_user") + val isLockedForUser: Boolean = false, + @SerializedName("hidden_for_user") + val isHiddenForUser: Boolean = false, - // File Attributes - @SerializedName("folder_id") - val folderId: Long = 0, - val size: Long = 0, - @SerializedName("content-type") - val contentType: String? = null, - val url: String? = null, - @SerializedName("display_name") - val displayName: String? = null, - @SerializedName("thumbnail_url") - val thumbnailUrl: String? = null, - @SerializedName("lock_info") - val lockInfo: LockInfo? = null, + // File Attributes + @SerializedName("folder_id") + val folderId: Long = 0, + val size: Long = 0, + @SerializedName("content-type") + val contentType: String? = null, + val url: String? = null, + @SerializedName("display_name") + val displayName: String? = null, + @SerializedName("thumbnail_url") + val thumbnailUrl: String? = null, + @SerializedName("lock_info") + val lockInfo: LockInfo? = null, - // Folder Attributes - @SerializedName("parent_folder_id") - val parentFolderId: Long = 0, - @SerializedName("context_id") - val contextId: Long = 0, - @SerializedName("files_count") - val filesCount: Int = 0, - val position: Int = 0, - @SerializedName("folders_count") - val foldersCount: Int = 0, - @SerializedName("context_type") - val contextType: String? = null, - val name: String? = null, - @SerializedName("folders_url") - val foldersUrl: String? = null, - @SerializedName("files_url") - val filesUrl: String? = null, - @SerializedName("full_name") - val fullName: String? = null, - @SerializedName("usage_rights") - var usageRights: UsageRights? = null, - @SerializedName("for_submissions") - var forSubmissions: Boolean = false, // Only for folders - @SerializedName("can_upload") - val canUpload: Boolean = false, - var avatar: Avatar? = null // Used to get a file token to update avatars with vanity URLs + // Folder Attributes + @SerializedName("parent_folder_id") + val parentFolderId: Long = 0, + @SerializedName("context_id") + val contextId: Long = 0, + @SerializedName("files_count") + val filesCount: Int = 0, + val position: Int = 0, + @SerializedName("folders_count") + val foldersCount: Int = 0, + @SerializedName("context_type") + val contextType: String? = null, + val name: String? = null, + @SerializedName("folders_url") + val foldersUrl: String? = null, + @SerializedName("files_url") + val filesUrl: String? = null, + @SerializedName("full_name") + val fullName: String? = null, + @SerializedName("usage_rights") + var usageRights: UsageRights? = null, + @SerializedName("for_submissions") + var forSubmissions: Boolean = false, // Only for folders + @SerializedName("can_upload") + val canUpload: Boolean = false, + var avatar: Avatar? = null, // Used to get a file token to update avatars with vanity URLs + @SerializedName("visibility_level") + val visibilityLevel: String? = null ) : CanvasModel() { val isRoot: Boolean get() = parentFolderId == 0L val isFile: Boolean get() = !displayName.isNullOrBlank() @@ -102,12 +104,20 @@ data class FileFolder( /* We override compareTo instead of using Canvas Comparable methods */ override fun compareTo(other: FileFolder) = compareFiles(this, other) - private fun compareFiles(file1: FileFolder, file2: FileFolder): Int{ + private fun compareFiles(file1: FileFolder, file2: FileFolder): Int { return when { (file1.fullName == null && file2.fullName != null) -> 1 (file1.fullName != null && file2.fullName == null) -> -1 - (file1.fullName != null && file2.fullName != null) -> NaturalOrderComparator.compare(file1.fullName.lowercase(Locale.getDefault()), file2.fullName.lowercase(Locale.getDefault())) - else -> NaturalOrderComparator.compare(file1.displayName?.lowercase(Locale.getDefault()), file2.displayName?.lowercase(Locale.getDefault())) + (file1.fullName != null && file2.fullName != null) -> NaturalOrderComparator.compare( + file1.fullName.lowercase( + Locale.getDefault() + ), file2.fullName.lowercase(Locale.getDefault()) + ) + + else -> NaturalOrderComparator.compare( + file1.displayName?.lowercase(Locale.getDefault()), + file2.displayName?.lowercase(Locale.getDefault()) + ) } } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/ModuleContentDetails.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/ModuleContentDetails.kt index 6a282f2979..30e9f31b18 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/ModuleContentDetails.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/ModuleContentDetails.kt @@ -25,25 +25,29 @@ import java.util.* @Parcelize data class ModuleContentDetails( - @SerializedName("points_possible") - val pointsPossible: String? = null, - @SerializedName("due_at") - val dueAt: String? = null, - @SerializedName("unlock_at") - val unlockAt: String? = null, - @SerializedName("lock_at") - val lockAt: String? = null, - @SerializedName("locked_for_user") - val lockedForUser: Boolean = false, - @SerializedName("lock_explanation") - val lockExplanation: String? = null, - @SerializedName("lock_info") - val lockInfo: LockInfo? = null + @SerializedName("points_possible") + val pointsPossible: String? = null, + @SerializedName("due_at") + val dueAt: String? = null, + @SerializedName("unlock_at") + val unlockAt: String? = null, + @SerializedName("lock_at") + val lockAt: String? = null, + @SerializedName("locked_for_user") + val lockedForUser: Boolean = false, + @SerializedName("lock_explanation") + val lockExplanation: String? = null, + @SerializedName("lock_info") + val lockInfo: LockInfo? = null, + val hidden: Boolean? = null, + val locked: Boolean? = null ) : CanvasComparable() { @IgnoredOnParcel val dueDate: Date? get() = dueAt.toDate() + @IgnoredOnParcel val unlockDate: Date? get() = unlockAt.toDate() + @IgnoredOnParcel val lockDate: Date? get() = lockAt.toDate() } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Progress.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Progress.kt index 6b371e21c6..2116772320 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Progress.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Progress.kt @@ -33,7 +33,7 @@ data class Progress( @SerializedName("workflow_state") private val workflowState: String = "", // One of 'queued', 'running', 'completed', 'failed' val tag: String = "", // The type of operation - val completion: Long = 0, // Percent completed + val completion: Float = 0f, // Percent completed val message: String? = null // Optional details about the job ) : CanvasModel() { val isQueued: Boolean get() = workflowState == "queued" diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/postmodels/BulkUpdateResponse.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/postmodels/BulkUpdateResponse.kt new file mode 100644 index 0000000000..667df98d3f --- /dev/null +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/postmodels/BulkUpdateResponse.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 - 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.models.postmodels + +import com.instructure.canvasapi2.models.Progress + +data class BulkUpdateResponse( + val progress: BulkUpdateProgress? = null +) + +data class BulkUpdateProgress( + val progress: Progress? = null +) \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/DateHelper.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/DateHelper.kt index a54ae08819..d5b30749b0 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/DateHelper.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/DateHelper.kt @@ -88,7 +88,8 @@ object DateHelper { fun getFormattedTime(context: Context?, date: Date?): String? { - return context?.let { getPreferredTimeFormat(it).format(date) } + if (context == null || date == null) return null + return getPreferredTimeFormat(context).format(date) } fun createPrefixedDateString(context: Context?, prefix: String, date: Date?): String? { diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/tasks/LogoutTask.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/tasks/LogoutTask.kt index b3111f8ece..39ff8cc9cb 100644 --- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/tasks/LogoutTask.kt +++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/tasks/LogoutTask.kt @@ -24,7 +24,9 @@ import com.instructure.canvasapi2.CanvasRestAdapter import com.instructure.canvasapi2.builders.RestBuilder import com.instructure.canvasapi2.managers.CommunicationChannelsManager import com.instructure.canvasapi2.managers.OAuthManager -import com.instructure.canvasapi2.utils.* +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.canvasapi2.utils.MasqueradeHelper import com.instructure.canvasapi2.utils.weave.weave import com.instructure.loginapi.login.util.PreviousUsersUtils import com.instructure.pandautils.models.PushNotification @@ -56,6 +58,8 @@ abstract class LogoutTask( protected open fun stopOfflineSync() = Unit + protected open suspend fun cancelAlarms() = Unit + @Suppress("EXPERIMENTAL_FEATURE_WARNING") fun execute() { try { @@ -82,6 +86,8 @@ abstract class LogoutTask( stopOfflineSync() + cancelAlarms() + when (type) { Type.LOGOUT, Type.LOGOUT_NO_LOGIN_FLOW -> { removeOfflineData(ApiPrefs.user?.id) diff --git a/libs/pandares/src/main/res/drawable/ic_add_lined.xml b/libs/pandares/src/main/res/drawable/ic_add_lined.xml new file mode 100644 index 0000000000..2267332ead --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_add_lined.xml @@ -0,0 +1,9 @@ + + + diff --git a/libs/pandares/src/main/res/drawable/ic_calendar_month.xml b/libs/pandares/src/main/res/drawable/ic_calendar_month.xml new file mode 100644 index 0000000000..a656223aec --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_calendar_month.xml @@ -0,0 +1,28 @@ + + + + + diff --git a/libs/pandares/src/main/res/drawable/ic_eye_off.xml b/libs/pandares/src/main/res/drawable/ic_eye_off.xml new file mode 100644 index 0000000000..bfb6d91deb --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_eye_off.xml @@ -0,0 +1,28 @@ + + + + + diff --git a/libs/pandares/src/main/res/drawable/ic_notifications_lined.xml b/libs/pandares/src/main/res/drawable/ic_notifications_lined.xml new file mode 100644 index 0000000000..43f5fb5a69 --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_notifications_lined.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/pandares/src/main/res/drawable/ic_publish.xml b/libs/pandares/src/main/res/drawable/ic_publish.xml new file mode 100644 index 0000000000..a8827c653a --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_publish.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libs/pandares/src/main/res/drawable/ic_refresh.xml b/libs/pandares/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000000..951de8c80b --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/libs/pandares/src/main/res/drawable/ic_unpublish.xml b/libs/pandares/src/main/res/drawable/ic_unpublish.xml new file mode 100644 index 0000000000..09e495e99a --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_unpublish.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/pandares/src/main/res/font/balsamiq_regular.ttf b/libs/pandares/src/main/res/font/balsamiq_regular.ttf new file mode 100644 index 0000000000..5f3febff07 Binary files /dev/null and b/libs/pandares/src/main/res/font/balsamiq_regular.ttf differ diff --git a/libs/pandares/src/main/res/values-ar/strings.xml b/libs/pandares/src/main/res/values-ar/strings.xml index 7b9f7c7478..400a75f0e8 100644 --- a/libs/pandares/src/main/res/values-ar/strings.xml +++ b/libs/pandares/src/main/res/values-ar/strings.xml @@ -71,6 +71,59 @@ مفقود تم تقييم الدرجة + التذكير + أضف إعلامات تذكير تاريخ الاستحقاق بشأن هذه المهمة على هذا الجهاز. + إضافة التذكير + إزالة التذكير + %s قبل + + %d من الدقائق + دقيقة واحدة + %d من الدقائق + %d من الدقائق + %d من الدقائق + %d من الدقائق + + + %d من الساعات + ساعة واحدة + %d من الساعات + %d من الساعات + %d من الساعات + %d من الساعات + + + %d من الأيام + يوم واحد + %d من الأيام + %d من الأيام + %d من الأيام + %d من الأيام + + + %d من الأسابيع + 1 أسبوع + %d من الأسابيع + %d من الأسابيع + %d من الأسابيع + %d من الأسابيع + + مخصص + تذكير مخصص + الكمية + دقائق قبل + ساعات قبل + أيام قبل + أسابيع قبل + إعلامات التذكير + إعلامات Canvas لتذكيرات المهام. + تذكير تاريخ الاستحقاق + هذه المهمة مستحقة في %s: %s + يرجى اختيار وقت مستقبلي لتذكيرك! + لقد قمت بالفعل بتعيين تذكير لهذا الوقت + حذف التذكير + هل حقًا ترغب في حذف هذا التذكير؟ + يجب أن تقوم بتمكين إذن التنبيه الدقيق لهذا الإجراء لا توجد معاينة متوفرة لعناوين URL باستخدام \'http://\' يُرجى إدخال عنوان URL صالح @@ -1560,4 +1613,5 @@ تمت مزامنة %d من المساقات. محتوى مساق إضافي + فشل تحديث ترتيب بطاقات لوحة المعلومات 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 e7558e09d5..2c8e219f1e 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 @@ -68,6 +68,43 @@ Mangler Bedømt + Påmindelse + Tilføj påmindelser om afleveringsdato for denne opgave på denne enhed. + Tilføj påmindelse + Fjern påmindelse + %s før + + 1 minut + %d minutter + + + 1 time + %d timer + + + 1 dag + %d dage + + + 1 uge + %d uger + + Brugerdefineret + Brugerdefineret påmindelse + Antal + Minutter før + Timer før + Dage før + Uger før + Påmindelsesmeddelelser + Canvas-meddelelser for opgavepåmindelser. + Påmindelse om afleveringsdato + Denne opgave skal afleveres om %s: %s + Vælg et fremtidigt tidspunkt for din påmindelse! + Du har allerede indstillet en påmindelse for dette tidspunkt + Slet påmindelse + Er du sikker på, at du vil slette denne påmindelse? + Du skal aktivere nøjagtig alarmtilladelse for denne handling Der findes ingen forhåndsvisning for URL’er, der bruger \'http://\' Indtast venligst en gyldig URL @@ -1481,4 +1518,5 @@ %d fagene er blevet synkroniseret. Yderligere fagindhold + Kunne ikke opdatere oversigtens kortordren 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 608efeeba3..2db17bcd15 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 @@ -68,6 +68,43 @@ Missing Graded + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder + %s Before + + 1 Minute + %d Minutes + + + 1 Hour + %d Hours + + + 1 Day + %d Days + + + 1 Week + %d Weeks + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before + Reminder Notifications + Canvas Notifications for assignment reminders. + Due Date Reminder + This assignment is due in %s: %s + Please choose a future time for your reminder! + You have already set a reminder for this time + Delete Reminder + Are you sure you would like to delete this reminder? + You need to enable exact alarm permission for this action No preview available for URLs using \'http://\' Please enter a valid URL @@ -1481,4 +1518,5 @@ %d subjects have been synced. Additional subject content + Failed to update Dashboard cards order 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 97fe4127f9..6eb60cea44 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 @@ -68,6 +68,43 @@ Missing Graded + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder + %s Before + + 1 Minute + %d Minutes + + + 1 Hour + %d Hours + + + 1 Day + %d Days + + + 1 Week + %d Weeks + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before + Reminder Notifications + Canvas Notifications for assignment reminders. + Due Date Reminder + This assignment is due in %s: %s + Please choose a future time for your reminder! + You have already set a reminder for this time + Delete Reminder + Are you sure you would like to delete this reminder? + You need to enable exact alarm permission for this action No preview available for URLs using \'http://\' Please enter a valid URL @@ -1481,4 +1518,5 @@ %d modules have been synced. Additional module content + Failed to update Dashboard cards order 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 e02f78f1c4..dd33e1e55a 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 @@ -68,6 +68,43 @@ Mangler Vurdert + Påminnelse + Legg til varsler for påminnelse om forfallsdato for oppgaven på denne enheten. + Legg til påminnelse + Fjern påminnelse + %s før + + 1 minutt + %d minutter + + + 1 time + %d timer + + + 1 dag + %d dager + + + 1 uke + %d uker + + Tilpasset + Tilpasset påminnelse + Antall + Minutter før + Timer før + Dager før + Uker før + Påminnelsesvarslinger + Canvas-varslinger for oppgavepåminnelser. + Påminnelse om forfallsdato + Denne oppgaven har frist om %s: %s + Velg en fremtidig forfallsdato for påminnelsen din! + Du har allerede angitt en påminnelse for dette tidspunktet + Slett påminnelse + Er du sikker på at du vil slette denne påminnelsen? + Du må aktivere nøyaktig alarmtillatelse for denne handlingen. Det er ingen forhåndsvisnings-URL ved bruk av \'http://\' Skriv inn gyldig URL @@ -1482,4 +1519,5 @@ %d fag er synkronisert. Ytterligere faginnhold + Kunne ikke oppdatere rekkefølge på oversiktskort 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 46d0d717b5..ac8b92c870 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 @@ -68,6 +68,43 @@ Saknas Har bedömts + Påminnelse + Lägg till påminnelse om inlämningsdatum om denna uppgift på denna enhet. + Lägg till påminnelse + Ta bort påminnelse + %s Före + + 1 minut + %d minuter + + + 1 timme + %d timmar + + + 1 dag + %d dagar + + + 1 vecka + %d veckor + + Anpassa + Anpassad påminnelse + Kvantitet + Minuter före + Timmar före + Dagar före + Veckor före + Påminnelser + Canvas-meddelanden för påminnelser om uppgifter + Påminnelse om inlämning + Denna uppgift ska lämnas in %s: %s + Välj en tid i framtiden för din påminnelse! + Du har redan ställt in en påminnelse för den här tiden + Radera påminnelse + Är du säker på att du vill radera den här påminnelsen? + Du måste aktivera behörigheten för exakt larm för den här åtgärden. Ingen förhandsgranskning är tillgänglig för URL:er som använder \'http://\' Ange en giltig URL @@ -1481,4 +1518,5 @@ %d-kurser har synkroniserats. Extra kursinnehåll + Det gick inte att uppdatera översiktens kortordning 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 f4dafa9d93..23ca780ba0 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 @@ -67,6 +67,39 @@ 缺少 已評分 + 提醒 + 在此裝置上添加有關此作業的截止日期提醒通知。 + 添加提醒 + 移除提醒 + %s 前 + + %d 分鐘 + + + %d 小時 + + + %d 天 + + + %d 週 + + 自訂 + 自訂提醒 + 數量 + 分鐘前 + 小時前 + 天前 + 週前 + 提醒通知 + 使用於作業提醒的 Canvas 通知。 + 截止日期提醒 + 此作業截止於 %s:%s + 請選擇您將來的提醒時間! + 您已經為此時間設定了提醒 + 刪除提醒 + 是否確定要刪除此提醒? + 您需要為此動作啟用確切的警報權限 沒有使用 \'http://\' 的 URL 的可用預覽 請輸入有效的 URL @@ -1461,4 +1494,5 @@ %d 課程已同步。 額外課程內容 + 無法更新控制面板卡片順序 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 7149d2279e..0617c4530c 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 @@ -67,6 +67,39 @@ 缺失 已评分 + 提醒 + 在本设备上添加关于此作业的截止日期提醒通知。 + 添加提醒 + 删除提醒 + 提前的%s + + %d 分钟 + + + %d 小时 + + + %d 天 + + + %d 周 + + 自定义 + 自定义提醒 + 数量 + 提前的分钟数 + 提前的小时数 + 提前的天数 + 提前的周数 + 提醒通知 + Canvas 作业提醒通知。 + 截止日期提醒 + 此作业在%s后截止:%s + 请选择未来的提醒日期! + 您已经为该时间设置提醒 + 删除提醒 + 是否确实要删除此提醒? + 您需要为此操作启用精确警报许可 使用“http://”的 URL 无可用预览 请输入有效的 URL @@ -1461,4 +1494,5 @@ %d 门课程已同步。 更多课程内容 + 无法更新控制面板卡片顺序 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 f4dafa9d93..23ca780ba0 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 @@ -67,6 +67,39 @@ 缺少 已評分 + 提醒 + 在此裝置上添加有關此作業的截止日期提醒通知。 + 添加提醒 + 移除提醒 + %s 前 + + %d 分鐘 + + + %d 小時 + + + %d 天 + + + %d 週 + + 自訂 + 自訂提醒 + 數量 + 分鐘前 + 小時前 + 天前 + 週前 + 提醒通知 + 使用於作業提醒的 Canvas 通知。 + 截止日期提醒 + 此作業截止於 %s:%s + 請選擇您將來的提醒時間! + 您已經為此時間設定了提醒 + 刪除提醒 + 是否確定要刪除此提醒? + 您需要為此動作啟用確切的警報權限 沒有使用 \'http://\' 的 URL 的可用預覽 請輸入有效的 URL @@ -1461,4 +1494,5 @@ %d 課程已同步。 額外課程內容 + 無法更新控制面板卡片順序 diff --git a/libs/pandares/src/main/res/values-ca/strings.xml b/libs/pandares/src/main/res/values-ca/strings.xml index 890fee6e6f..4281c11c5d 100644 --- a/libs/pandares/src/main/res/values-ca/strings.xml +++ b/libs/pandares/src/main/res/values-ca/strings.xml @@ -68,6 +68,43 @@ No presentat Qualificat + Recordatori + Afegiu notificacions de recordatori de dates de lliurament d\'aquesta activitat a aquest dispositiu. + Afegeix un recordatori + Suprimeix el recordatori + %s anterior + + 1 minut + %d minuts + + + 1 hora + %d hores + + + 1 dia + %d dies + + + 1 setmana + %d setmanes + + Personalitzat + Recordatori personalitzat + Quantitat + Minuts anteriors + Hores anteriors + Dies anteriors + Setmanes anteriors + Notificacions de recordatoris + Notificacions del Canvas per a recordatoris d’activitats. + Recordatori de data de lliurament + Aquesta activitat s’ha de lliurar el %s: %s + Trieu una data futura per al recordatori + Ja heu establert un recordatori per a aquesta data + Suprimeix el recordatori + Segur que voleu suprimir aquest recordatori? + Heu d\'activar el permís d’alarma exacte per a aquesta acció Si es fa servir \'http://\', no hi ha cap visualització prèvia disponible dels URL Introduïu un URL vàlid @@ -1482,4 +1519,5 @@ S’han sincronitzat %d assignatures. Contingut de l’assignatura addicional + No s’ha pogut actualitzar la comanda de targetes del panell de control diff --git a/libs/pandares/src/main/res/values-cy/strings.xml b/libs/pandares/src/main/res/values-cy/strings.xml index e970abdb83..abcde4e9b9 100644 --- a/libs/pandares/src/main/res/values-cy/strings.xml +++ b/libs/pandares/src/main/res/values-cy/strings.xml @@ -68,6 +68,43 @@ Ar goll Wedi graddio + Nodyn atgoffa + Ychwanegu hysbysiad atgoffa o ddyddiad erbyn am yr aseiniad hwn ar y ddyfais hon. + Ychwanegu nodyn atgoffa + Tynnu nodyn atgoffa + %s Cyn + + 1 munud + %d Munud + + + 1 awr + %d Awr + + + 1 diwrnod + %d Diwrnod + + + 1 Wythnos + %d Wythnos + + Personol + Nodyn Atgoffa Personol + Swm + Munud Cyn + Awr Cyn + Diwrnod Cyn + Wythnos Cyn + Hysbysiadau Atgoffa + Hysbysiadau Canvas ar gyfer nodiadau atgoffa o aseiniad + Nodyn Atgoffa o Ddyddiad Erbyn + Mae’r aseiniad hwn angen ei gyflwyno erbyn mewn %s: %s + Dewiswch amser yn y dyfodol ar gyfer eich nodyn atgoffa! + Rydych chi eisoes wedi gosod nodyn atgoffa ar gyfer yr amser hwn + Dileu Nodyn Atgoffa + Ydych chi’n siŵr eich bod am ddileu’r nodyn atgoffa hwn? + Mae angen i chi alluogi hawl larwm penodol ar gyfer y cam gweithredu hwn Does dim rhagolwg ar gael ar gyfer URL sy\'n defnyddio \'http://\' Rhowch URL dilys @@ -1481,4 +1518,5 @@ %d cwrs wedi cael eu cysoni. Cynnwys cwrs ychwanegol + Wedi methu diweddaru trefn cardiau’r Dangosfwrdd diff --git a/libs/pandares/src/main/res/values-da/strings.xml b/libs/pandares/src/main/res/values-da/strings.xml index 3b2a5a3be3..1f2c7986a5 100644 --- a/libs/pandares/src/main/res/values-da/strings.xml +++ b/libs/pandares/src/main/res/values-da/strings.xml @@ -68,6 +68,43 @@ Mangler Bedømt + Påmindelse + Tilføj påmindelser om afleveringsdato for denne opgave på denne enhed. + Tilføj påmindelse + Fjern påmindelse + %s før + + 1 minut + %d minutter + + + 1 time + %d timer + + + 1 dag + %d dage + + + 1 uge + %d uger + + Brugerdefineret + Brugerdefineret påmindelse + Antal + Minutter før + Timer før + Dage før + Uger før + Påmindelsesmeddelelser + Canvas-meddelelser for opgavepåmindelser. + Påmindelse om afleveringsdato + Denne opgave skal afleveres om %s: %s + Vælg et fremtidigt tidspunkt for din påmindelse! + Du har allerede indstillet en påmindelse for dette tidspunkt + Slet påmindelse + Er du sikker på, at du vil slette denne påmindelse? + Du skal aktivere nøjagtig alarmtilladelse for denne handling Der findes ingen forhåndsvisning for URL’er, der bruger \'http://\' Indtast venligst en gyldig URL @@ -1481,4 +1518,5 @@ %d fagene er blevet synkroniseret. Yderligere fagindhold + Kunne ikke opdatere oversigtens kortordren diff --git a/libs/pandares/src/main/res/values-de/strings.xml b/libs/pandares/src/main/res/values-de/strings.xml index 582f85287c..a0b12c826d 100644 --- a/libs/pandares/src/main/res/values-de/strings.xml +++ b/libs/pandares/src/main/res/values-de/strings.xml @@ -68,6 +68,43 @@ Fehlt Benotet + Erinnerung + Fügen Sie Erinnerungsbenachrichtigungen zu dieser Aufgabe auf diesem Gerät hinzu. + Erinnerung hinzufügen + Erinnerung entfernen + %s Vor + + 1 Minute + %d Minuten + + + 1 Stunde + %d Stunden + + + 1 Tag + %d Tage + + + 1 Woche + %d Wochen + + Angepasst + Benutzerdefinierte Erinnerung + Menge + Minuten vorher + Stunden vorher + Tage vorher + Wochen vorher + Erinnerungsbenachrichtigungen + Canvas-Benachrichtigungen für Aufgabenerinnerungen. + Fälligkeitserinnerung + Diese Aufgabe ist fällig in %s: %s + Bitte wählen Sie eine zukünftige Zeit für Ihre Erinnerung! + Sie haben bereits eine Erinnerung für diese Zeit eingestellt + Erinnerung löschen + Sind Sie sicher, dass Sie diese Erinnerung löschen möchten? + Sie müssen für diese Aktion die genaue Alarmberechtigung aktivieren Für URLs, die \'http://\' verwenden, ist keine Vorschau verfügbar. Geben Sie bitte eine gültige URL ein. @@ -1481,4 +1518,5 @@ %d Kurse wurden synchronisiert. Zusätzliche Kursinhalte + Reihenfolge der Dashboard-Karten konnte nicht aktualisiert werden 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 d980349add..3afd34175a 100644 --- a/libs/pandares/src/main/res/values-en-rAU/strings.xml +++ b/libs/pandares/src/main/res/values-en-rAU/strings.xml @@ -68,6 +68,43 @@ Missing Marked + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder + %s Before + + 1 Minute + %d Minutes + + + 1 Hour + %d Hours + + + 1 Day + %d Days + + + 1 Week + %d Weeks + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before + Reminder Notifications + Canvas Notifications for assignment reminders. + Due Date Reminder + This assignment is due in %s: %s + Please choose a future time for your reminder! + You have already set a reminder for this time + Delete Reminder + Are you sure you would like to delete this reminder? + You need to enable exact alarm permission for this action No preview available for URLs using \'http://\' Please enter a valid URL @@ -1481,4 +1518,5 @@ %d courses have been synced. Additional course content + Failed to update Dashboard cards order 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 97fe4127f9..6eb60cea44 100644 --- a/libs/pandares/src/main/res/values-en-rCY/strings.xml +++ b/libs/pandares/src/main/res/values-en-rCY/strings.xml @@ -68,6 +68,43 @@ Missing Graded + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder + %s Before + + 1 Minute + %d Minutes + + + 1 Hour + %d Hours + + + 1 Day + %d Days + + + 1 Week + %d Weeks + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before + Reminder Notifications + Canvas Notifications for assignment reminders. + Due Date Reminder + This assignment is due in %s: %s + Please choose a future time for your reminder! + You have already set a reminder for this time + Delete Reminder + Are you sure you would like to delete this reminder? + You need to enable exact alarm permission for this action No preview available for URLs using \'http://\' Please enter a valid URL @@ -1481,4 +1518,5 @@ %d modules have been synced. Additional module content + Failed to update Dashboard cards order 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 24c584678f..670d7f2794 100644 --- a/libs/pandares/src/main/res/values-en-rGB/strings.xml +++ b/libs/pandares/src/main/res/values-en-rGB/strings.xml @@ -68,6 +68,43 @@ Missing Graded + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder + %s Before + + 1 Minute + %d Minutes + + + 1 Hour + %d Hours + + + 1 Day + %d Days + + + 1 Week + %d Weeks + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before + Reminder Notifications + Canvas Notifications for assignment reminders. + Due Date Reminder + This assignment is due in %s: %s + Please choose a future time for your reminder! + You have already set a reminder for this time + Delete Reminder + Are you sure you would like to delete this reminder? + You need to enable exact alarm permission for this action No preview available for URLs using \'http://\' Please enter a valid URL @@ -1481,4 +1518,5 @@ %d courses have been synced. Additional course content + Failed to update Dashboard cards order 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 2df0e3f03b..f9d488801c 100644 --- a/libs/pandares/src/main/res/values-es-rES/strings.xml +++ b/libs/pandares/src/main/res/values-es-rES/strings.xml @@ -68,6 +68,43 @@ No presentado Evaluado + Recordatorio + Añadir notificaciones para el recordatorio de la fecha de entrega sobre esta actividad en este dispositivo. + Añadir recordatorio + Eliminar recordatorio + %s antes + + 1 minuto + %d minutos + + + 1 hora + %d horas + + + 1 día + %d días + + + 1 semana + %d semanas + + Personalizar + Personalizar recordatorio + Cantidad + Minutos antes + Horas antes + Días antes + Semanas antes + Recordar notificaciones + Notificaciones de Canvas para los recordatorios de la actividad. + Recordatorio de la fecha de entrega + Esta actividad debe entregarse el %s: %s + ¡Elige una hora posterior para tu recordatorio! + Ya has configurado un recordatorio para esta hora + Eliminar recordatorio + ¿Estás seguro de que quieres eliminar este recordatorio? + Necesitas habilitar el permiso de alarma exacta para realizar esta acción No hay vistas previas disponibles para las URL que usan \'http://\' Introduce una URL válida @@ -1483,4 +1520,5 @@ %d cursos se han sincronizado. Contenido del curso adicional + Error al actualizar el orden de las tarjetas en el cuadro de mando diff --git a/libs/pandares/src/main/res/values-es/strings.xml b/libs/pandares/src/main/res/values-es/strings.xml index 5f78299787..a3d2c4eecc 100644 --- a/libs/pandares/src/main/res/values-es/strings.xml +++ b/libs/pandares/src/main/res/values-es/strings.xml @@ -68,6 +68,43 @@ Deficiente Calificado + Recordatorio + Agregar notificaciones de recordatorio de fecha de entrega para esta tarea en este dispositivo. + Agregar recordatorio + Eliminar recordatorio + %s Antes + + 1 minuto + %d minutos + + + 1 hora + %d horas + + + 1 día + %d días + + + 1 semana + %d semanas + + Personalizar + Personalizar Recordatorio + Cantidad + Minutos antes + Horas antes + Días antes + Semanas antes + Notificaciones de recordatorio + Notificaciones de Canvas para recordatorios de tareas. + Recordatorio de fecha de entrega + La tarea se vence en %s: %s + Elija un horario futuro para su recordatorio. + Ya ha creado un recordatorio para este horario. + Eliminar Recordatorio + ¿Seguro que desea eliminar este recordatorio? + Necesita habilitar un permiso de alarma exacto para completar esta acción. No hay vistas previas disponibles para las URL que usan \'http://\' Ingrese una URL válida @@ -1481,4 +1518,5 @@ Se han sincronizado los cursos %d. Contenido adicional del curso + No se pudo actualizar el orden de las tarjetas del tablero diff --git a/libs/pandares/src/main/res/values-fi/strings.xml b/libs/pandares/src/main/res/values-fi/strings.xml index be20b9c54e..fcaf19fae0 100644 --- a/libs/pandares/src/main/res/values-fi/strings.xml +++ b/libs/pandares/src/main/res/values-fi/strings.xml @@ -68,6 +68,43 @@ Puuttuu Arvosteltu + Muistutus + Lisää päivämäärän muistutuksia tästä tehtävästä tällä laitteella. + Lisää muistutus + Poista muistutus + %s Ennen + + 1 minuutti + %d minuuttia + + + 1 tunti + %d tuntia + + + 1 päivä + %d päivää + + + 1 viikko + %d viikkoa + + Mukautettu + Mukautettu muistutus + Määrä + Minuuttia ennen + Tuntia ennen + Päivää ennen + Viikkoa ennen + Muistutukset + Canvas-ilmoitukset tehtävämuistutuksille. + Päivämäärämuistutukset + Tämän tehtävän määräpäivä on %s %s + Valitse tuleva aika muistutuksellesi! + Olet jo asettanut muistutuksen tähän ajankohtaan + Poista muistutus + Haluatko varmasti poistaa tämän muistutuksen? + Sinun täytyy ottaa käyttöön täsmälliset hälytyksen käyttöoikeudet tälle toiminnolle Ei saatavissa esikatselua URL-linkeille, jotka käyttävät \'http://\' Syötä voimassa oleva URL @@ -1481,4 +1518,5 @@ %d kurssia on synkronoitu. LIsäkurssin sisältö. + Hallintapaneelin koontinäytön korttien päivittäminen epäonnistui 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 900b9b79b2..ebabfdafd4 100644 --- a/libs/pandares/src/main/res/values-fr-rCA/strings.xml +++ b/libs/pandares/src/main/res/values-fr-rCA/strings.xml @@ -68,6 +68,43 @@ Manquant Noté + Rappel + Ajouter des notifications de rappel de date d’échéance concernant cette tâche sur cet appareil. + Ajouter un rappel + Retirer un rappel + %s Avant + + 1 Minute + %d Minutes + + + 1 heure + %d heures + + + 1 jour + %d jours + + + 1 semaine + %d semaines + + Personnalisé + Rappel personnalisé + Quantité + Minutes avant + Heures avant + Jours avant + Semaines avant + Notifications de rappel + Notifications Canvas pour les rappels de tâches. + Rappel de la date d’échéance + Cette tâche est dû dans %s : %s + Veuillez choisir une heure ultérieure pour votre rappel! + Vous avez déjà défini un rappel pour cette heure + Supprimer le rappel + Voulez-vous vraiment supprimer ce rappel? + Vous devez activer l’autorisation d’alarme exacte pour cette action Aucun aperçu disponible pour les URL utilisant http:// Veuillez saisir une URL valide @@ -1481,4 +1518,5 @@ %d cours ont été synchronisés. Contenu supplémentaire de cours + Échec de la mise à jour de la commande des cartes du tableau de bord diff --git a/libs/pandares/src/main/res/values-fr/strings.xml b/libs/pandares/src/main/res/values-fr/strings.xml index a3e56bdf2a..bfecb2f221 100644 --- a/libs/pandares/src/main/res/values-fr/strings.xml +++ b/libs/pandares/src/main/res/values-fr/strings.xml @@ -68,6 +68,43 @@ Manquant Noté + Rappel + Ajouter des notifications de rappel de date limite pour ce travail sur cet appareil. + Ajouter le rappel + Supprimer le rappel + %s à l\'avance + + 1 minute + %d Minutes + + + 1 heure + %d heures + + + 1 jour + %d Jours + + + 1 semaine + %d semaines + + Personnalisé + Rappel personnalisé + Quantité + Minutes à l\'avance + Heures à l\'avance + Jours à l\'avance + Semaines à l\'avance + Notifications de rappel + Notifications Canvas de rappel de travail. + Rappel de date limite + Ce travail est attendu dans %s : %s + Veuillez choisir une heure pour votre rappel ! + Vous avez déjà défini un rappel à cette heure. + Supprimer le rappel + Voulez-vous vraiment supprimer ce rappel ? + Vous devez activer la bonne autorisation d\'alarme pour cette action Aucun aperçu disponible pour les URLs utilisant « http:// » Veuillez saisir une URL valide @@ -1481,4 +1518,5 @@ %d cours ont été synchronisés. Contenu de cours supplémentaire + Impossible de mettre à jour l’ordre des cartes du tableau de bord diff --git a/libs/pandares/src/main/res/values-ht/strings.xml b/libs/pandares/src/main/res/values-ht/strings.xml index 6d8b1151c8..2a8d1c352c 100644 --- a/libs/pandares/src/main/res/values-ht/strings.xml +++ b/libs/pandares/src/main/res/values-ht/strings.xml @@ -68,6 +68,43 @@ Manke Klase + Rapèl + Ajoute notifikasyon rapèl delè pou devwa sa a sou aparèy sa a. + Ajoute rapèl + Elimine rapèl + %s Anvan + + 1 Minit + %d Minit + + + 1 èdtan + %d Èdtan + + + 1 Jou + %d Jou + + + 1 Semèn + %d Semèn + + Pèsonalize + Rapèl Pèsonalize + Kantite + Minit Anvan + Èdtan Anvan + Jou Anvan + Semèn Anvan + Notifikasyon Rapèl + Notifikasyon Canvas pou rapèl devwa yo. + Rapèl Delè + Devwa sa a dwe remèt %s: %s + Chwazi yon dat apre pou rapèl ou a! + Ou gentan fikse yon rapèl pou lè sa a + Elimine Rapèl + Èske w sèten ou vle elimine rapèl sa a? + Ou dwe aktive otorizasyon alam egzat la pou aksyon sa a Okenn apèsi disponib pou URL ki itilize \'http://\' Tanpri antre yon URL ki valid @@ -1481,4 +1518,5 @@ %d kou yo senkwonize. Plis kontni kou + Echèk mizajou kòmann kat tablodbò a diff --git a/libs/pandares/src/main/res/values-id/strings.xml b/libs/pandares/src/main/res/values-id/strings.xml index 073cc18e07..37180e7e64 100644 --- a/libs/pandares/src/main/res/values-id/strings.xml +++ b/libs/pandares/src/main/res/values-id/strings.xml @@ -68,6 +68,43 @@ Tidak Ada Dinilai + Pengingat + Tambah notifikasi pengingat tanggal batas waktu tentang tugas ini pada perangkat ini. + Tambah pengingat + Hapus pengingat + %s Sebelum + + 1 Menit + %d Menit + + + 1 Jam + %d Jam + + + 1 Hari + %d Hari + + + 1 Minggu + %d Minggu + + Khusus + Pengingat Kustom + Jumlah + Menit Sebelum + Jam Sebelum + Hari Sebelum + Minggu Sebelum + Notifikasi Pengingat + Notifikasi Canvas untuk pengingat tugas. + Pengingat Tanggal Batas Waktu + Tugas ini jatuh tempo dalam %s: %s + Silakan pilih waktu di masa depan untuk pengingat Anda! + Anda sudah mengatur pengingat untuk waktu ini + Hapus Pengingat + Anda yakin mau menghapus pengingat ini? + Anda harus mampu menetapkan izin alarm untuk tindakan ini Pratinjau tidak tersedia untuk URL yang menggunakan \'http://\' Masukkan URL yang valid. @@ -1484,4 +1521,5 @@ Kursus %d telah disinkronkan. Konten kursus tambahan + Gagal memperbarui urutan kartu Dashboard diff --git a/libs/pandares/src/main/res/values-is/strings.xml b/libs/pandares/src/main/res/values-is/strings.xml index 2de8421654..e72b1018dc 100644 --- a/libs/pandares/src/main/res/values-is/strings.xml +++ b/libs/pandares/src/main/res/values-is/strings.xml @@ -68,6 +68,43 @@ Vantar Metið + Áminning + Bættu við áminningartilkynningum um skiladag um þetta verkefni á þessu tæki. + Bæta við áminningu + Fjarlægja áminningu + %s áður + + 1 mínútu + %d mínútur + + + 1 klukkustund + %d klukkustundir + + + 1 dag + %d daga + + + 1 viku + %d vikur + + Sérsnið + Sérsniðin áminning + Magn + Mínútum áður + Klukkutímum áður + Dögum áður + Vikum áður + Áminningartilkynningar + Canvas Tilkynningar fyrir áminningar um verkefni. + Áminning um skiladag + Skiladagur þessa verkefnis er eftir %s: %s + Vinsamlegast veldu framtíðartíma fyrir áminningu þína! + Þú hefur þegar sett áminningu fyrir þennan tíma + Eyða áminningu + Viltu örugglega eyða þessari áminningu? + Þú þarft að virkja nákvæmt viðvörunarleyfi fyrir þessa aðgerð Engin forskoðun tiltæk fyrir vefslóð sem nota \'http://\' Settu inn gilda vefslóð @@ -1481,4 +1518,5 @@ %d námskeið hafa verið samstillt. Viðbótar námskeiðsefni + Ekki tókst að uppfæra röð Skjáborðsspjalda diff --git a/libs/pandares/src/main/res/values-it/strings.xml b/libs/pandares/src/main/res/values-it/strings.xml index b170295b0d..d2718c0d31 100644 --- a/libs/pandares/src/main/res/values-it/strings.xml +++ b/libs/pandares/src/main/res/values-it/strings.xml @@ -68,6 +68,43 @@ Mancante Valutato + Promemoria + Aggiungi notifiche di promemoria della data di scadenza su questo compito su questo dispositivo. + Aggiungi promemoria + Rimuovi promemoria + %s Prima + + 1 minuto + %d minuti + + + 1 ora + %d ore + + + 1 giorno + %d giorni + + + 1 settimana + %d settimane + + Personalizzato + Promemoria personalizzato + Quantità + Minuti prima + Ore prima + Giorni prima + Settimane prima + Notifiche promemoria + Notifica Canvas per promemoria compito. + Promemoria data di scadenza + Questo compito scade in %s: %s + Scegliere un periodo futuro per il promemoria! + Hai già impostato un promemoria per questo periodo + Elimina promemoria + Vuoi eliminare questo promemoria? + Devi abilitare l’autorizzazione di allarme esatto per questa operazione Nessuna anteprima disponibile per gli URL che utilizzano \'http://\' Inserisci un URL valido @@ -1481,4 +1518,5 @@ %d corsi sono stati sincronizzati. Altro contenuto del corso + Impossibile aggiornare l’ordine delle schede dashboard diff --git a/libs/pandares/src/main/res/values-ja/strings.xml b/libs/pandares/src/main/res/values-ja/strings.xml index a426dd9484..5de5425ac7 100644 --- a/libs/pandares/src/main/res/values-ja/strings.xml +++ b/libs/pandares/src/main/res/values-ja/strings.xml @@ -67,6 +67,39 @@ 欠如 採点済み + 事前通知 + このデバイスにこの課題に関する期限リマインダ通知を追加します。 + リマインダを追加する + リマインダを削除する + %s前 + + %d 分 + + + %d時間 + + + %d 日 + + + %d 週間 + + カスタム + カスタムリマインダ + 数量 + 分前 + 時間前 + 日前 + 週前 + リマインダ通知 + Canvasの課題に関する通知 + 期日のリマインダ + この課題の提出期限は%s以内:%s + リマインダの時間には未来の時間を選択してください! + あなたはすでにこの時間にリマインダを設定しています + リマインダを削除する + このリマインダ本当に削除しますか? + この操作を行うには、正確なアラーム許可を有効にする必要があります。 URL を \'http://\' でプレビューすることはできません 有効なURLを入力してください @@ -1319,7 +1352,7 @@ 受講者の提出がある場合は課題を取り消すことはできません。 カレンダーフィードを購読する このダイアログで「定期購読」ボタンをクリックすると、CanvasカレンダーをGoogleカレンダーアカウントに同期させることができます。その後、デバイスのGoogleカレンダーアプリで、新しいカレンダーの設定で同期を有効にする必要があります。 - 定期購読 + 常に通知 ライトモードに切り替える ダークモードに切り替える あなたは新しいユーザーであるか、最後に同意して以降、許可される使用ポリシーが変更されています。続行する前に許可される使用ポリシーに同意してください。 @@ -1331,7 +1364,7 @@ %d 試行 質問:%d 時間制限:%s - 試行許可数:%s + 試行許可回数:%s 試行した回数:%d 予期しないエラーが発生しました。 @@ -1461,4 +1494,5 @@ %dコースが同期されました。 追加のコースコンテンツ + ダッシュボードのカードの順番を更新できませんでした diff --git a/libs/pandares/src/main/res/values-mi/strings.xml b/libs/pandares/src/main/res/values-mi/strings.xml index 181bde810e..ac24ae565b 100644 --- a/libs/pandares/src/main/res/values-mi/strings.xml +++ b/libs/pandares/src/main/res/values-mi/strings.xml @@ -68,6 +68,43 @@ Ngaro kōekehia + Whakamaumahara + Tāpirihia nga whakamohiotanga whakamaumahara mo te ra tika mo tenei taumahi i runga i tenei taputapu. + Tāpiri whakamaumahara + Tango whakamaumahara + %s I mua + + 1 meneti + %d meneti + + + 1 Haora + %d Ngā Haora + + + 1 Ra + %d Ngā Ra + + + 1 Wiki + %d Nga wiki + + Tikanga + Whakamaumahara Ritenga + Te nui + Nga meneti o mua + Nga haora i mua + Nga ra o mua + Nga wiki o mua + Panui Whakamaumahara + Whakamōhiotanga Canvas mō ngā whakamaumahara taumahi. + Whakamaumahara ki te Ra Whakatau + Ka tika tenei taumahi i roto %s: %s + Tena koa whiriwhiria he wa kei te heke mai mo to whakamaumahara! + Kua tautuhia e koe he whakamaumahara mo tenei wa + Muku Whakamaumahara + E tino hiahia ana koe ki te muku i tenei whakamaumahara? + Me whakaahei koe i te whakaaetanga whakaoho mo tenei mahi Kaore e wātea he arokite mo ngā URL e mahi ana http// Tēnā koa whakauru he URL whaimana @@ -1481,4 +1518,5 @@ %d kua tukutahia nga akoranga. Ihirangi akoranga taapiri + I rahua te whakahōu i te raupapa kāri Papatohu diff --git a/libs/pandares/src/main/res/values-ms/strings.xml b/libs/pandares/src/main/res/values-ms/strings.xml index 78ae096d79..b832724d45 100644 --- a/libs/pandares/src/main/res/values-ms/strings.xml +++ b/libs/pandares/src/main/res/values-ms/strings.xml @@ -68,6 +68,43 @@ Tiada Digredkan + Peringatan + Tambah peringatan tarikh siap untuk tugasan ini pada peranti ini. + Tambah peringatan + Alih keluar peringatan + %s Sebelum + + 1 Minit + %d Minit + + + 1 Jam + %d Jam + + + 1 Hari + %d Hari + + + 1 Minggu + %d Minggu + + Tersuai + Peringatan Tersuai + Kuantiti + Minit Sebelum + Jam Sebelum + Hari Sebelum + Minggu Sebelum + Pemberitahuan Peringatan + Pemberitahuan Canvas untuk peringatan tugasan. + Peringatan Tarikh Siap + Tugasan ini perlu disiapkan dalam %s: %s + Sila pilih masa akan datang untuk peringatan anda! + Anda telah menetapkan peringatan untuk masa ini + Padam Peringatan + Adakah anda pasti anda ingin memadamkan peringatan ini? + Anda perlu mendayakan keizinan penggera yang tepat untuk tindakan ini Tiada pratonton tersedia untuk URL menggunakan \'http://\' Sila masukkan URL yang sah @@ -1487,4 +1524,5 @@ %d Kursus telah disegerakkan. Kandungan kursus tambahan + Gagal untuk mengemas kini susunan kad Papan Pemuka diff --git a/libs/pandares/src/main/res/values-nb/strings.xml b/libs/pandares/src/main/res/values-nb/strings.xml index 9d67eff9a5..2103ae6842 100644 --- a/libs/pandares/src/main/res/values-nb/strings.xml +++ b/libs/pandares/src/main/res/values-nb/strings.xml @@ -68,6 +68,43 @@ Mangler Vurdert + Påminnelse + Legg til varsler for påminnelse om forfallsdato for oppgaven på denne enheten. + Legg til påminnelse + Fjern påminnelse + %s før + + 1 minutt + %d minutter + + + 1 time + %d timer + + + 1 dag + %d dager + + + 1 uke + %d uker + + Tilpasset + Tilpasset påminnelse + Antall + Minutter før + Timer før + Dager før + Uker før + Påminnelsesvarslinger + Canvas-varslinger for oppgavepåminnelser. + Påminnelse om forfallsdato + Denne oppgaven har frist om %s: %s + Velg en fremtidig forfallsdato for påminnelsen din! + Du har allerede angitt en påminnelse for dette tidspunktet + Slett påminnelse + Er du sikker på at du vil slette denne påminnelsen? + Du må aktivere nøyaktig alarmtillatelse for denne handlingen. Det er ingen forhåndsvisnings-URL ved bruk av \'http://\' Skriv inn gyldig URL @@ -1482,4 +1519,5 @@ %d emner er synkronisert. Ytterligere emneinnhold + Kunne ikke oppdatere rekkefølge på Dashbord-kort diff --git a/libs/pandares/src/main/res/values-nl/strings.xml b/libs/pandares/src/main/res/values-nl/strings.xml index 7002b343d5..a71a87122c 100644 --- a/libs/pandares/src/main/res/values-nl/strings.xml +++ b/libs/pandares/src/main/res/values-nl/strings.xml @@ -68,6 +68,43 @@ Ontbrekend Beoordeeld + Herinnering + Voeg herinneringsmeldingen voor inleverdatum over deze opdracht toe op dit apparaat. + Herinnering toevoegen + Herinnering verwijderen + %s vóór + + 1 minuut + %d minuten + + + 1 uur + %d uur + + + 1 dag + %d dagen + + + 1 week + %d weken + + Aangepast + Aangepaste herinnering + Hoeveelheid + Minuten vóór + Uur vóór + Dagen vóór + Weken vóór + Herinneringsmeldingen + Canvas-meldingen voor opdrachtherinneringen. + Herinnering voor inleverdatum + Deze opdracht moet uiterlijk worden ingeleverd op %s %s + Kies een toekomstige tijd voor je herinnering! + Je hebt al een herinnering voor deze tijd ingesteld + Herinnering verwijderen + Weet je zeker dat je deze herinnering wilt verwijderen? + Je moet exacte alarmmachtiging inschakelen voor deze actie Geen voorbeeld beschikbaar voor URL\'s die \'http://\' gebruiken Voer een geldige URL in @@ -1481,4 +1518,5 @@ %d-cursussen zijn gesynchroniseerd. Aanvullende cursusinhoud + Kan de volgorde van Dashboard-kaarten niet wijzigen diff --git a/libs/pandares/src/main/res/values-pl/strings.xml b/libs/pandares/src/main/res/values-pl/strings.xml index df8ca14aae..3506aef4af 100644 --- a/libs/pandares/src/main/res/values-pl/strings.xml +++ b/libs/pandares/src/main/res/values-pl/strings.xml @@ -70,6 +70,51 @@ Brak Oceniono + Przypomnienie + Dodaj przypomnienie o terminie dla tego zadania, na tym urządzeniu. + Dodaj przypomnienie + Usuń przypomnienie + %s do + + 1 min + %d min + %d min + %d min + + + 1 godz. + %d godz. + %d godz. + %d godz. + + + 1 dzień + %d dni + %d dni + %d dni + + + 1 tyg. + %d tyg. + %d tyg. + %d tyg. + + Niestandardowe + Niestandardowe przypomnienie + Ilość + min. do + godz. do + dni do + tyg. do + Przypomnienia + Powiadomienia Canvas dotyczące przypomnień o zadaniach. + Przypomnienie o terminie + Termin przesłania tego zadania upłynie za %s: %s + Wybierz przyszły termin dla przypomnienia! + Ustawiono już przypomnienie dla tego terminu + Usuń przypomnienie + Czy na pewno chcesz usunąć to przypomnienie? + Aby użyć tego działania, należy włączyć uprawnienia alertów. Brak podglądu dla adresu URL z użyciem \'http://\' Wpisz prawidłowy adres URL @@ -1521,4 +1566,5 @@ Kursy %d zostały zsynchronizowane. Dodatkowa zawartość kursu + Nie udało się zaktualizować kolejności kart pulpitu nawigacyjnego 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 ed775bca10..fc76fee792 100644 --- a/libs/pandares/src/main/res/values-pt-rBR/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rBR/strings.xml @@ -68,6 +68,43 @@ Faltante Avaliado + Lembrete + Adicione notificações de lembrete de data de vencimento sobre essa tarefa nesse dispositivo. + Adicionar lembrete + Remover lembrete + %s antes + + 1 Minuto + %d Minutos + + + 1 Hora + %d Horas + + + 1 dia + %d dias + + + 1 semana + %d semanas + + Personalizar + Lembrete personalizado + Quantidade + Minutos antes + Horas antes + Dias antes + Semanas antes + Notificações de lembrete + Notificações do Canvas para lembretes de tarefas. + Lembrete de data de vencimento + Esta tarefa deve ser entregue em %s: %s + Escolha um horário futuro para seu lembrete! + Você já definiu um lembrete para esse horário + Excluir lembrete + Tem certeza de que deseja excluir esse lembrete? + Você precisa ativar a permissão exata de alarme para essa ação Nenhuma pré-visualização disponível para URLs usando \'http://\' Por favor, insira uma URL válida @@ -1481,4 +1518,5 @@ %d cursos foram sincronizados. Conteúdo adicional do curso + Falha ao atualizar a ordem dos cartões do Painel 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 b03b6d9f3d..7bad8df237 100644 --- a/libs/pandares/src/main/res/values-pt-rPT/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rPT/strings.xml @@ -68,6 +68,43 @@ Em falta Classificado + Lembrete + Adicionar notificações de lembrete de data de vencimento sobre este trabalho neste dispositivo. + Adicionar lembrete + Remover lembrete + %s Antes de + + 1 minuto + %d minutos + + + 1 hora + %d horas + + + 1 dia + %d Dias + + + 1 Semana + %d Semanas + + Personalizado + Lembrete personalizado + Quantidade + Minutos antes + Horas antes + Dias antes + Semanas antes + Notificações de lembrete + Notificações do Canvas para lembretes de tarefas. + Lembrete de data de entrega + Esta tarefa deve ser entregue em %s: %s + Escolha uma hora futura para o seu lembrete! + Já definiu um lembrete para esta hora + Eliminar lembrete + Tem a certeza de que pretende apagar este lembrete? + É necessário ativar a permissão de alarme exato para esta ação Nenhuma visualização disponível para URLs usando \'http://\' Por favor, insira um URL válido @@ -1481,4 +1518,5 @@ %d as disciplinas foram sincronizadas. Conteúdo adicional da disciplina + Falha ao atualizar a ordem dos cartões do Painel diff --git a/libs/pandares/src/main/res/values-ru/strings.xml b/libs/pandares/src/main/res/values-ru/strings.xml index b0df61bd35..0dd4d9f812 100644 --- a/libs/pandares/src/main/res/values-ru/strings.xml +++ b/libs/pandares/src/main/res/values-ru/strings.xml @@ -70,6 +70,51 @@ Отсутствует С оценкой + Напоминание + Добавьте уведомления с напоминанием о сроках выполнения этого задания на этом устройстве. + Добавить напоминание + Удалить напоминание + %s До + + 1 минута + %d минут + %d минут + %d минут + + + 1 час + %d часов + %d часов + %d часов + + + 1 день + %d дней + %d дней + %d дней + + + 1 неделя + %d недели (недель) + %d недели (недель) + %d недели (недель) + + Пользовательская + Пользовательское напоминание + Количество + Минут(ы) до + Часа(ов) до + Дня(ей) до + Недель(и) до + Уведомления с напоминанием + Уведомления Canvas для напоминаний о тестировании. + Напоминание о сроке выполнения + Данное задание необходимо выполнить через %s: %s + Выберите время в будущем для напоминания! + Вы уже установили напоминание на это время + Удалить напоминание + Вы действительно хотите удалить это напоминание? + Для этого действия необходимо включить разрешение на точное оповещение Предпросмотр для URL-адресов с использованием \'http://\' недоступен Введите действительный URL-адрес @@ -1521,4 +1566,5 @@ %d курса(-ов) были синхронизированы. Дополнительный контент курса + Не удалось обновить порядок карт на информационной панели diff --git a/libs/pandares/src/main/res/values-sl/strings.xml b/libs/pandares/src/main/res/values-sl/strings.xml index 634e388210..d59c7cf222 100644 --- a/libs/pandares/src/main/res/values-sl/strings.xml +++ b/libs/pandares/src/main/res/values-sl/strings.xml @@ -68,6 +68,43 @@ Manjkajoče Ocenjeno + Opomnik + Dodajte obvestila za opomnik o roku za to nalogo na tej napravi. + Dodaj opomnik + Odstrani opomnik + %s pred + + 1 minuta + %d minut + + + 1 ura + %d ur + + + 1 dan + %d dni(-evi) + + + 1 teden + %d tednov + + Po meri + Opomnik po meri + Količina + Minut pred + Ur pred + Dni pred + Tednov pred + Obvestila za opomnik + Obvestila Canvas za opomnike za naloge + Opomnik za rok + Ta naloga ima rok čez %s: %s + Za opomnik izberite čas v prihodnosti! + Za ta čas ste že nastavili opomnik + Odstrani opomnik + Ali ste prepričani, da želite ta opomnik odstraniti? + Za to dejanje morate omogočiti natančno dovoljenje za alarm Za naslove URL, ki uporabljajo »http://«, predogled ni na voljo. Vnesite veljaven naslov URL. @@ -1481,4 +1518,5 @@ %d predmetov je bilo sinhroniziranih. Dodatna vsebina predmeta + Vrstnega reda kartic nadzorne plošče ni bilo mogoče posodobiti diff --git a/libs/pandares/src/main/res/values-sv/strings.xml b/libs/pandares/src/main/res/values-sv/strings.xml index 57b0530642..19363df17a 100644 --- a/libs/pandares/src/main/res/values-sv/strings.xml +++ b/libs/pandares/src/main/res/values-sv/strings.xml @@ -68,6 +68,43 @@ Saknas Har bedömts + Påminnelse + Lägg till påminnelse om inlämningsdatum om denna uppgift på denna enhet. + Lägg till påminnelse + Ta bort påminnelse + %s Före + + 1 minut + %d minuter + + + 1 timme + %d timmar + + + 1 dag + %d dagar + + + 1 vecka + %d veckor + + Anpassa + Anpassad påminnelse + Kvantitet + Minuter före + Timmar före + Dagar före + Veckor före + Påminnelser + Canvas-meddelanden för påminnelser om uppgifter + Påminnelse om inlämning + Denna uppgift ska lämnas in %s: %s + Välj en tid i framtiden för din påminnelse! + Du har redan ställt in en påminnelse för den här tiden + Radera påminnelse + Är du säker på att du vill radera den här påminnelsen? + Du måste aktivera behörigheten för exakt larm för den här åtgärden. Ingen förhandsgranskning är tillgänglig för URL:er som använder \'http://\' Ange en giltig URL @@ -1481,4 +1518,5 @@ %d-kurser har synkroniserats. Extra kursinnehåll + Det gick inte att uppdatera översiktens kortordning diff --git a/libs/pandares/src/main/res/values-th/strings.xml b/libs/pandares/src/main/res/values-th/strings.xml index fa9be66bc5..a3282f2149 100644 --- a/libs/pandares/src/main/res/values-th/strings.xml +++ b/libs/pandares/src/main/res/values-th/strings.xml @@ -68,6 +68,43 @@ ขาดหาย ให้เกรดแล้ว + แจ้งเตือน + เพิ่มการแจ้งเตือนครบกำหนดเกี่ยวกับภารกิจในอุปกรณ์นี้ + เพิ่มการแจ้งเตือน + ลบการแจ้งเตือน + %s ก่อน + + 1 นาที + %d นาที + + + 1 ชั่วโมง + %d ชั่วโมง + + + 1 วัน + %d วัน + + + 1 สัปดาห์ + %d สัปดาห์ + + กำหนดเอง + การแจ้งเตือนกำหนดเอง + จำนวน + นาทีก่อนหน้า + ชั่วโมงก่อนหน้า + วันก่อนหน้า + สัปดาห์ก่อนหน้า + การแจ้งเตือน + การแจ้งข้อมูลจาก Canvas สำหรับการแจ้งเตือนภารกิจ + แจ้งเตือนวันครบกำหนด + ภารกิจนี้ครบกำหนดใน %s %s + กรุณาเลือกเวลาในอนาคตสำหรับการแจ้งเตือนของคุณ! + คุณกำหนดการแจ้งเตือนสำหรับเวลานี้ไปแล้ว + ลบการแจ้งเตือน + แน่ใจว่าต้องการลบการแจ้งเตือนนี้หรือไม่ + คุณจะต้องเปิดใช้สิทธิ์การแจ้งเตือนที่เจาะจงสำหรับการดำเนินการนี้ ไม่มีการแสดงตัวอย่างสำหรับ URL ที่ใช้ ‘http://’ กรุณากรอก URL ที่ถูกต้อง @@ -1481,4 +1518,5 @@ %d บทเรียนได้รับการซิงค์แล้ว เนื้อหาบทเรียนเพิ่มเติม + ไม่สามารถอัพเดตลำดับการ์ดแผงข้อมูล diff --git a/libs/pandares/src/main/res/values-vi/strings.xml b/libs/pandares/src/main/res/values-vi/strings.xml index ed23161453..e73a2afbb2 100644 --- a/libs/pandares/src/main/res/values-vi/strings.xml +++ b/libs/pandares/src/main/res/values-vi/strings.xml @@ -68,6 +68,43 @@ Bị Thiếu Đã Chấm Điểm + Lời nhắc nhở + Thêm thông báo lời nhắc nhở ngày đến hạn về bài tập này trên thiết bị này. + Thêm lời nhắc nhở + Gỡ lời nhắc nhở + %s Trước + + 1 Phút + %d Phút + + + 1 Giờ + %d Giờ + + + 1 Ngày + %d Ngày + + + 1 Tuần + %d Tuần + + Tùy Chỉnh + Lời Nhắc Nhở Tùy Chỉnh + Số Lượng + Phút Trước + Giờ Trước + Ngày Trước + Tuần Trước + Thông Báo Lời Nhắc Nhở + Thông Báo Canvas cho lời nhắc nhở bài tập. + Lời Nhắc Nhở Ngày Đến Hạn + Bài tập này đến hạn vào %s: %s + Vui lòng chọn thời gian trong tương lai cho lời nhắc nhở của bạn! + Bạn đã thiết lập lời nhắc nhở cho thời gian này + Xóa Lời Nhắc Nhở + Bạn có chắc chắn muốn xóa lời nhắc nhở này không? + Bạn cần bật quyền báo động chính xác cho hành động này Không có mục xem trước có thể sử dụng cho URL sử dụng \"http://\" Vui lòng nhập URL hợp lệ @@ -1482,4 +1519,5 @@ %d khóa học đã được đồng bộ. Nội dung khóa học bổ sung + Không thể cập nhật thứ tự thẻ Bảng Điều Khiển diff --git a/libs/pandares/src/main/res/values-zh/strings.xml b/libs/pandares/src/main/res/values-zh/strings.xml index 7149d2279e..0617c4530c 100644 --- a/libs/pandares/src/main/res/values-zh/strings.xml +++ b/libs/pandares/src/main/res/values-zh/strings.xml @@ -67,6 +67,39 @@ 缺失 已评分 + 提醒 + 在本设备上添加关于此作业的截止日期提醒通知。 + 添加提醒 + 删除提醒 + 提前的%s + + %d 分钟 + + + %d 小时 + + + %d 天 + + + %d 周 + + 自定义 + 自定义提醒 + 数量 + 提前的分钟数 + 提前的小时数 + 提前的天数 + 提前的周数 + 提醒通知 + Canvas 作业提醒通知。 + 截止日期提醒 + 此作业在%s后截止:%s + 请选择未来的提醒日期! + 您已经为该时间设置提醒 + 删除提醒 + 是否确实要删除此提醒? + 您需要为此操作启用精确警报许可 使用“http://”的 URL 无可用预览 请输入有效的 URL @@ -1461,4 +1494,5 @@ %d 门课程已同步。 更多课程内容 + 无法更新控制面板卡片顺序 diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 1e2639fb23..c665eb497f 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -67,6 +67,43 @@ Submission Deleted Missing Graded + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder + %s Before + + 1 Minute + %d Minutes + + + 1 Hour + %d Hours + + + 1 Day + %d Days + + + 1 Week + %d Weeks + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before + Reminder Notifications + Canvas Notifications for assignment reminders. + Due Date Reminder + This assignment is due in %s: %s + Please choose a future time for your reminder! + You have already set a reminder for this time + Delete Reminder + Are you sure you would like to delete this reminder? + You need to enable exact alarm permission for this action No preview available for URLs using \'http://\' @@ -1491,4 +1528,76 @@ Additional course content Failed to update Dashboard cards order + Publish all Modules and Items + Publish Modules only + Unpublish all Modules and Items + Module Options + Publish Module and all Items + Publish Module only + Unpublish Module and all Items + Module options for %s + Module item options for %s + Publish Module Item + Unpublish Module Item + Publish? + This will make only the module visible to students. + This will make the module and all items visible to students. + Unpublish? + This will make the module and all items invisible to students. + This will make only this item visible to students. + This will make only this item invisible to students. + This will make all modules and items visible to students. + This will make only the modules visible to students. + This will make all modules and items invisible to students. + Only available with link + Schedule availability + Item published + Item unpublished + Only Module published + Module and all Items published + Module and all Items unpublished + Only Modules published + All Modules and all Items published + All Modules and all Items unpublished + Inherit from Course + Course Members + Institution Members + Public + Edit Permissions + Update + Availability + Visibility + Available From + Available Until + From + Until + Date + Time + Clear From Date + Clear Until Date + This process could take a few minutes. You may close the modal or navigate away from the page during this process. + Note + All Modules + All Modules and Items + Selected Modules and Items + Selected Modules + Publishing + Unpublishing + Modules and items that have already been processed will not be reverted to their previous state when the process is discontinued. + Success! + Update failed + Update cancelled + Hidden + Scheduled + Published + Unpublished + + %.0f pt + %.0f pts + + Publish + Unpublish + Publish + Unpublish + Refresh diff --git a/libs/pandautils/build.gradle b/libs/pandautils/build.gradle index 2353288f5c..bea7fc7f93 100644 --- a/libs/pandautils/build.gradle +++ b/libs/pandautils/build.gradle @@ -86,15 +86,21 @@ android { buildFeatures { dataBinding true viewBinding true + compose true } hilt { enableAggregatingTask = false + enableExperimentalClasspathAggregation = true } sourceSets { debug.assets.srcDirs += files("$projectDir/schemas".toString()) } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.KOTLIN_COMPOSE_COMPILER_VERSION + } } tasks.withType(Test) { @@ -194,6 +200,18 @@ dependencies { kapt Libs.ROOM_COMPILER implementation Libs.ROOM_COROUTINES + /* Compose */ + def composeBom = platform(Libs.COMPOSE_BOM) + implementation composeBom + androidTestImplementation composeBom + + implementation Libs.COMPOSE_BOM + implementation Libs.COMPOSE_MATERIAL + implementation Libs.COMPOSE_PREVIEW + debugImplementation Libs.COMPOSE_TOOLING + implementation Libs.COMPOSE_VIEW_MODEL + implementation Libs.COMPOSE_UI + implementation Libs.FLEXBOX_LAYOUT // We need to add this dependency if we update ExoPlayer to 2.12.0 @@ -209,4 +227,7 @@ dependencies { androidTestImplementation Libs.ANDROIDX_TEST_JUNIT androidTestImplementation Libs.ANDROIDX_CORE_TESTING androidTestImplementation Libs.ROOM_TEST + + androidTestImplementation Libs.COMPOSE_UI_TEST + debugImplementation Libs.COMPOSE_UI_TEST_MANIFEST } diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/10.json b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/10.json new file mode 100644 index 0000000000..6e46f92cd7 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/10.json @@ -0,0 +1,616 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "175d57d3fa38e37eb607adb69a222821", + "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": [] + }, + { + "tableName": "ReminderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `htmlUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `text` TEXT NOT NULL, `time` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ModuleBulkProgressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`progressId` INTEGER NOT NULL, `allModules` INTEGER NOT NULL, `skipContentTags` INTEGER NOT NULL, `action` TEXT NOT NULL, `courseId` INTEGER NOT NULL, `affectedIds` TEXT NOT NULL, PRIMARY KEY(`progressId`))", + "fields": [ + { + "fieldPath": "progressId", + "columnName": "progressId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allModules", + "columnName": "allModules", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipContentTags", + "columnName": "skipContentTags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "affectedIds", + "columnName": "affectedIds", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "progressId" + ] + }, + "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, '175d57d3fa38e37eb607adb69a222821')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/9.json b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/9.json new file mode 100644 index 0000000000..d1a943762c --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/9.json @@ -0,0 +1,566 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "d1a4b121027a0855fb86782dbdc1d7cd", + "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": [] + }, + { + "tableName": "ReminderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `htmlUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `text` TEXT NOT NULL, `time` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "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, 'd1a4b121027a0855fb86782dbdc1d7cd')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/2.json b/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/2.json new file mode 100644 index 0000000000..732f0ee4b0 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/2.json @@ -0,0 +1,5515 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "1182046fc85ec1b16d7693e1a593081b", + "entities": [ + { + "tableName": "AssignmentDueDateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`assignmentId` INTEGER NOT NULL, `assignmentOverrideId` INTEGER, `dueAt` TEXT, `title` TEXT, `unlockAt` TEXT, `lockAt` TEXT, `isBase` INTEGER NOT NULL, PRIMARY KEY(`assignmentId`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentOverrideId", + "columnName": "assignmentOverrideId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isBase", + "columnName": "isBase", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "assignmentId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT, `description` TEXT, `submissionTypesRaw` TEXT NOT NULL, `dueAt` TEXT, `pointsPossible` REAL NOT NULL, `courseId` INTEGER NOT NULL, `isGradeGroupsIndividually` INTEGER NOT NULL, `gradingType` TEXT, `needsGradingCount` INTEGER NOT NULL, `htmlUrl` TEXT, `url` TEXT, `quizId` INTEGER NOT NULL, `isUseRubricForGrading` INTEGER NOT NULL, `rubricSettingsId` INTEGER, `allowedExtensions` TEXT NOT NULL, `submissionId` INTEGER, `assignmentGroupId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `isPeerReviews` INTEGER NOT NULL, `lockedForUser` INTEGER NOT NULL, `lockAt` TEXT, `unlockAt` TEXT, `lockExplanation` TEXT, `discussionTopicHeaderId` INTEGER, `freeFormCriterionComments` INTEGER NOT NULL, `published` INTEGER NOT NULL, `groupCategoryId` INTEGER NOT NULL, `userSubmitted` INTEGER NOT NULL, `unpublishable` INTEGER NOT NULL, `onlyVisibleToOverrides` INTEGER NOT NULL, `anonymousPeerReviews` INTEGER NOT NULL, `moderatedGrading` INTEGER NOT NULL, `anonymousGrading` INTEGER NOT NULL, `allowedAttempts` INTEGER NOT NULL, `plannerOverrideId` INTEGER, `isStudioEnabled` INTEGER NOT NULL, `inClosedGradingPeriod` INTEGER NOT NULL, `annotatableAttachmentId` INTEGER NOT NULL, `anonymousSubmissions` INTEGER NOT NULL, `omitFromFinalGrade` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`assignmentGroupId`) REFERENCES `AssignmentGroupEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "submissionTypesRaw", + "columnName": "submissionTypesRaw", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pointsPossible", + "columnName": "pointsPossible", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGradeGroupsIndividually", + "columnName": "isGradeGroupsIndividually", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gradingType", + "columnName": "gradingType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "needsGradingCount", + "columnName": "needsGradingCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quizId", + "columnName": "quizId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUseRubricForGrading", + "columnName": "isUseRubricForGrading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rubricSettingsId", + "columnName": "rubricSettingsId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "allowedExtensions", + "columnName": "allowedExtensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentGroupId", + "columnName": "assignmentGroupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPeerReviews", + "columnName": "isPeerReviews", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockedForUser", + "columnName": "lockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockExplanation", + "columnName": "lockExplanation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "discussionTopicHeaderId", + "columnName": "discussionTopicHeaderId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "freeFormCriterionComments", + "columnName": "freeFormCriterionComments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupCategoryId", + "columnName": "groupCategoryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userSubmitted", + "columnName": "userSubmitted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unpublishable", + "columnName": "unpublishable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "onlyVisibleToOverrides", + "columnName": "onlyVisibleToOverrides", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "anonymousPeerReviews", + "columnName": "anonymousPeerReviews", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "moderatedGrading", + "columnName": "moderatedGrading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "anonymousGrading", + "columnName": "anonymousGrading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowedAttempts", + "columnName": "allowedAttempts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "plannerOverrideId", + "columnName": "plannerOverrideId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isStudioEnabled", + "columnName": "isStudioEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inClosedGradingPeriod", + "columnName": "inClosedGradingPeriod", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "annotatableAttachmentId", + "columnName": "annotatableAttachmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "anonymousSubmissions", + "columnName": "anonymousSubmissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "omitFromFinalGrade", + "columnName": "omitFromFinalGrade", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentGroupEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentGroupId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentGroupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT, `position` INTEGER NOT NULL, `groupWeight` REAL NOT NULL, `rules` TEXT, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupWeight", + "columnName": "groupWeight", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rules", + "columnName": "rules", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentOverrideEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `title` TEXT, `dueAt` INTEGER, `isAllDay` INTEGER NOT NULL, `allDayDate` TEXT, `unlockAt` INTEGER, `lockAt` INTEGER, `courseSectionId` INTEGER NOT NULL, `groupId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isAllDay", + "columnName": "isAllDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allDayDate", + "columnName": "allDayDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseSectionId", + "columnName": "courseSectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentRubricCriterionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`assignmentId` INTEGER NOT NULL, `rubricId` TEXT NOT NULL, PRIMARY KEY(`assignmentId`, `rubricId`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rubricId", + "columnName": "rubricId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "assignmentId", + "rubricId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentScoreStatisticsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`assignmentId` INTEGER NOT NULL, `mean` REAL NOT NULL, `min` REAL NOT NULL, `max` REAL NOT NULL, PRIMARY KEY(`assignmentId`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mean", + "columnName": "mean", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "min", + "columnName": "min", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "max", + "columnName": "max", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "assignmentId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentSetEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `scoringRangeId` INTEGER NOT NULL, `createdAt` TEXT, `updatedAt` TEXT, `position` INTEGER NOT NULL, `masteryPathId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`masteryPathId`) REFERENCES `MasteryPathEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scoringRangeId", + "columnName": "scoringRangeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "masteryPathId", + "columnName": "masteryPathId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "MasteryPathEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "masteryPathId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "CourseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `originalName` TEXT, `courseCode` TEXT, `startAt` TEXT, `endAt` TEXT, `syllabusBody` TEXT, `hideFinalGrades` INTEGER NOT NULL, `isPublic` INTEGER NOT NULL, `license` TEXT NOT NULL, `termId` INTEGER, `needsGradingCount` INTEGER NOT NULL, `isApplyAssignmentGroupWeights` INTEGER NOT NULL, `currentScore` REAL, `finalScore` REAL, `currentGrade` TEXT, `finalGrade` TEXT, `isFavorite` INTEGER NOT NULL, `accessRestrictedByDate` INTEGER NOT NULL, `imageUrl` TEXT, `bannerImageUrl` TEXT, `isWeightedGradingPeriods` INTEGER NOT NULL, `hasGradingPeriods` INTEGER NOT NULL, `homePage` TEXT, `restrictEnrollmentsToCourseDate` INTEGER NOT NULL, `workflowState` TEXT, `homeroomCourse` INTEGER NOT NULL, `courseColor` TEXT, `gradingScheme` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`termId`) REFERENCES `TermEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalName", + "columnName": "originalName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseCode", + "columnName": "courseCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startAt", + "columnName": "startAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endAt", + "columnName": "endAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "syllabusBody", + "columnName": "syllabusBody", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideFinalGrades", + "columnName": "hideFinalGrades", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "termId", + "columnName": "termId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "needsGradingCount", + "columnName": "needsGradingCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isApplyAssignmentGroupWeights", + "columnName": "isApplyAssignmentGroupWeights", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentScore", + "columnName": "currentScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "finalScore", + "columnName": "finalScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "currentGrade", + "columnName": "currentGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "finalGrade", + "columnName": "finalGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessRestrictedByDate", + "columnName": "accessRestrictedByDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerImageUrl", + "columnName": "bannerImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isWeightedGradingPeriods", + "columnName": "isWeightedGradingPeriods", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasGradingPeriods", + "columnName": "hasGradingPeriods", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homePage", + "columnName": "homePage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "restrictEnrollmentsToCourseDate", + "columnName": "restrictEnrollmentsToCourseDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workflowState", + "columnName": "workflowState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "homeroomCourse", + "columnName": "homeroomCourse", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseColor", + "columnName": "courseColor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingScheme", + "columnName": "gradingScheme", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "TermEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "termId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "CourseFilesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`courseId`, `url`), FOREIGN KEY(`courseId`) REFERENCES `CourseSyncSettingsEntity`(`courseId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId", + "url" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseSyncSettingsEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "courseId" + ] + } + ] + }, + { + "tableName": "CourseGradingPeriodEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `gradingPeriodId` INTEGER NOT NULL, PRIMARY KEY(`courseId`, `gradingPeriodId`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`gradingPeriodId`) REFERENCES `GradingPeriodEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gradingPeriodId", + "columnName": "gradingPeriodId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId", + "gradingPeriodId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GradingPeriodEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "gradingPeriodId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "CourseSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `courseSummary` INTEGER, `restrictQuantitativeData` INTEGER NOT NULL, PRIMARY KEY(`courseId`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseSummary", + "columnName": "courseSummary", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "restrictQuantitativeData", + "columnName": "restrictQuantitativeData", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "CourseSyncSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `courseName` TEXT NOT NULL, `fullContentSync` INTEGER NOT NULL, `tabs` TEXT NOT NULL, `fullFileSync` INTEGER NOT NULL, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseName", + "columnName": "courseName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullContentSync", + "columnName": "fullContentSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabs", + "columnName": "tabs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullFileSync", + "columnName": "fullFileSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isK5Subject` INTEGER NOT NULL, `shortName` TEXT, `originalName` TEXT, `courseCode` TEXT, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isK5Subject", + "columnName": "isK5Subject", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originalName", + "columnName": "originalName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseCode", + "columnName": "courseCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DiscussionEntryAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`discussionEntryId` INTEGER NOT NULL, `remoteFileId` INTEGER NOT NULL, PRIMARY KEY(`discussionEntryId`, `remoteFileId`), FOREIGN KEY(`discussionEntryId`) REFERENCES `DiscussionEntryEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`remoteFileId`) REFERENCES `RemoteFileEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "discussionEntryId", + "columnName": "discussionEntryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteFileId", + "columnName": "remoteFileId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "discussionEntryId", + "remoteFileId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionEntryEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "discussionEntryId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "RemoteFileEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "remoteFileId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DiscussionEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `unread` INTEGER NOT NULL, `updatedAt` TEXT, `createdAt` TEXT, `authorId` INTEGER, `description` TEXT, `userId` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `message` TEXT, `deleted` INTEGER NOT NULL, `totalChildren` INTEGER NOT NULL, `unreadChildren` INTEGER NOT NULL, `ratingCount` INTEGER NOT NULL, `ratingSum` INTEGER NOT NULL, `editorId` INTEGER NOT NULL, `_hasRated` INTEGER NOT NULL, `replyIds` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChildren", + "columnName": "totalChildren", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadChildren", + "columnName": "unreadChildren", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingCount", + "columnName": "ratingCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingSum", + "columnName": "ratingSum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editorId", + "columnName": "editorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "_hasRated", + "columnName": "_hasRated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyIds", + "columnName": "replyIds", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DiscussionParticipantEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `pronouns` TEXT, `avatarImageUrl` TEXT, `htmlUrl` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarImageUrl", + "columnName": "avatarImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DiscussionTopicHeaderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, `discussionType` TEXT, `title` TEXT, `message` TEXT, `htmlUrl` TEXT, `postedDate` INTEGER, `delayedPostDate` INTEGER, `lastReplyDate` INTEGER, `requireInitialPost` INTEGER NOT NULL, `discussionSubentryCount` INTEGER NOT NULL, `readState` TEXT, `unreadCount` INTEGER NOT NULL, `position` INTEGER NOT NULL, `assignmentId` INTEGER, `locked` INTEGER NOT NULL, `lockedForUser` INTEGER NOT NULL, `lockExplanation` TEXT, `pinned` INTEGER NOT NULL, `authorId` INTEGER, `podcastUrl` TEXT, `groupCategoryId` TEXT, `announcement` INTEGER NOT NULL, `permissionId` INTEGER, `published` INTEGER NOT NULL, `allowRating` INTEGER NOT NULL, `onlyGradersCanRate` INTEGER NOT NULL, `sortByRating` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `lockAt` INTEGER, `userCanSeePosts` INTEGER NOT NULL, `specificSections` TEXT, `anonymousState` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`authorId`) REFERENCES `DiscussionParticipantEntity`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`permissionId`) REFERENCES `DiscussionTopicPermissionEntity`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discussionType", + "columnName": "discussionType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "postedDate", + "columnName": "postedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "delayedPostDate", + "columnName": "delayedPostDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastReplyDate", + "columnName": "lastReplyDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "requireInitialPost", + "columnName": "requireInitialPost", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discussionSubentryCount", + "columnName": "discussionSubentryCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readState", + "columnName": "readState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockedForUser", + "columnName": "lockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockExplanation", + "columnName": "lockExplanation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "podcastUrl", + "columnName": "podcastUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupCategoryId", + "columnName": "groupCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "announcement", + "columnName": "announcement", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionId", + "columnName": "permissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowRating", + "columnName": "allowRating", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "onlyGradersCanRate", + "columnName": "onlyGradersCanRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortByRating", + "columnName": "sortByRating", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userCanSeePosts", + "columnName": "userCanSeePosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specificSections", + "columnName": "specificSections", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "anonymousState", + "columnName": "anonymousState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionParticipantEntity", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "authorId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "DiscussionTopicPermissionEntity", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "permissionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DiscussionTopicPermissionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `discussionTopicHeaderId` INTEGER NOT NULL, `attach` INTEGER NOT NULL, `update` INTEGER NOT NULL, `delete` INTEGER NOT NULL, `reply` INTEGER NOT NULL, FOREIGN KEY(`discussionTopicHeaderId`) REFERENCES `DiscussionTopicHeaderEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discussionTopicHeaderId", + "columnName": "discussionTopicHeaderId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attach", + "columnName": "attach", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delete", + "columnName": "delete", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reply", + "columnName": "reply", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionTopicHeaderEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "discussionTopicHeaderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DiscussionTopicRemoteFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`discussionId` INTEGER NOT NULL, `remoteFileId` INTEGER NOT NULL, PRIMARY KEY(`discussionId`, `remoteFileId`), FOREIGN KEY(`discussionId`) REFERENCES `DiscussionTopicHeaderEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`remoteFileId`) REFERENCES `RemoteFileEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "discussionId", + "columnName": "discussionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteFileId", + "columnName": "remoteFileId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "discussionId", + "remoteFileId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionTopicHeaderEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "discussionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "RemoteFileEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "remoteFileId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DiscussionTopicSectionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`discussionTopicId` INTEGER NOT NULL, `sectionId` INTEGER NOT NULL, PRIMARY KEY(`discussionTopicId`, `sectionId`), FOREIGN KEY(`discussionTopicId`) REFERENCES `DiscussionTopicHeaderEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`sectionId`) REFERENCES `SectionEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "discussionTopicId", + "columnName": "discussionTopicId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionId", + "columnName": "sectionId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "discussionTopicId", + "sectionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionTopicHeaderEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "discussionTopicId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "SectionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "EnrollmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `role` TEXT NOT NULL, `type` TEXT NOT NULL, `courseId` INTEGER, `courseSectionId` INTEGER, `enrollmentState` TEXT, `userId` INTEGER NOT NULL, `computedCurrentScore` REAL, `computedFinalScore` REAL, `computedCurrentGrade` TEXT, `computedFinalGrade` TEXT, `multipleGradingPeriodsEnabled` INTEGER NOT NULL, `totalsForAllGradingPeriodsOption` INTEGER NOT NULL, `currentPeriodComputedCurrentScore` REAL, `currentPeriodComputedFinalScore` REAL, `currentPeriodComputedCurrentGrade` TEXT, `currentPeriodComputedFinalGrade` TEXT, `currentGradingPeriodId` INTEGER NOT NULL, `currentGradingPeriodTitle` TEXT, `associatedUserId` INTEGER NOT NULL, `lastActivityAt` INTEGER, `limitPrivilegesToCourseSection` INTEGER NOT NULL, `observedUserId` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`observedUserId`) REFERENCES `UserEntity`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`courseSectionId`) REFERENCES `SectionEntity`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseSectionId", + "columnName": "courseSectionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enrollmentState", + "columnName": "enrollmentState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "computedCurrentScore", + "columnName": "computedCurrentScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "computedFinalScore", + "columnName": "computedFinalScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "computedCurrentGrade", + "columnName": "computedCurrentGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "computedFinalGrade", + "columnName": "computedFinalGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "multipleGradingPeriodsEnabled", + "columnName": "multipleGradingPeriodsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalsForAllGradingPeriodsOption", + "columnName": "totalsForAllGradingPeriodsOption", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPeriodComputedCurrentScore", + "columnName": "currentPeriodComputedCurrentScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "currentPeriodComputedFinalScore", + "columnName": "currentPeriodComputedFinalScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "currentPeriodComputedCurrentGrade", + "columnName": "currentPeriodComputedCurrentGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currentPeriodComputedFinalGrade", + "columnName": "currentPeriodComputedFinalGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currentGradingPeriodId", + "columnName": "currentGradingPeriodId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentGradingPeriodTitle", + "columnName": "currentGradingPeriodTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "associatedUserId", + "columnName": "associatedUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastActivityAt", + "columnName": "lastActivityAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "limitPrivilegesToCourseSection", + "columnName": "limitPrivilegesToCourseSection", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "observedUserId", + "columnName": "observedUserId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UserEntity", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "observedUserId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "SectionEntity", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "courseSectionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FileFolderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `createdDate` INTEGER, `updatedDate` INTEGER, `unlockDate` INTEGER, `lockDate` INTEGER, `isLocked` INTEGER NOT NULL, `isHidden` INTEGER NOT NULL, `isLockedForUser` INTEGER NOT NULL, `isHiddenForUser` INTEGER NOT NULL, `folderId` INTEGER NOT NULL, `size` INTEGER NOT NULL, `contentType` TEXT, `url` TEXT, `displayName` TEXT, `thumbnailUrl` TEXT, `parentFolderId` INTEGER NOT NULL, `contextId` INTEGER NOT NULL, `filesCount` INTEGER NOT NULL, `position` INTEGER NOT NULL, `foldersCount` INTEGER NOT NULL, `contextType` TEXT, `name` TEXT, `foldersUrl` TEXT, `filesUrl` TEXT, `fullName` TEXT, `forSubmissions` INTEGER NOT NULL, `canUpload` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdDate", + "columnName": "createdDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "updatedDate", + "columnName": "updatedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unlockDate", + "columnName": "unlockDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockDate", + "columnName": "lockDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLockedForUser", + "columnName": "isLockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHiddenForUser", + "columnName": "isHiddenForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextId", + "columnName": "contextId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filesCount", + "columnName": "filesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "foldersCount", + "columnName": "foldersCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextType", + "columnName": "contextType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "foldersUrl", + "columnName": "foldersUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesUrl", + "columnName": "filesUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fullName", + "columnName": "fullName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "forSubmissions", + "columnName": "forSubmissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUpload", + "columnName": "canUpload", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EditDashboardItemEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `name` TEXT NOT NULL, `isFavorite` INTEGER NOT NULL, `enrollmentState` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentState", + "columnName": "enrollmentState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ExternalToolAttributesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`assignmentId` INTEGER NOT NULL, `url` TEXT, `newTab` INTEGER NOT NULL, `resourceLinkid` TEXT, `contentId` INTEGER, PRIMARY KEY(`assignmentId`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "newTab", + "columnName": "newTab", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceLinkid", + "columnName": "resourceLinkid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "assignmentId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GradesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`enrollmentId` INTEGER NOT NULL, `htmlUrl` TEXT NOT NULL, `currentScore` REAL, `finalScore` REAL, `currentGrade` TEXT, `finalGrade` TEXT, PRIMARY KEY(`enrollmentId`), FOREIGN KEY(`enrollmentId`) REFERENCES `EnrollmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "enrollmentId", + "columnName": "enrollmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentScore", + "columnName": "currentScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "finalScore", + "columnName": "finalScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "currentGrade", + "columnName": "currentGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "finalGrade", + "columnName": "finalGrade", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "enrollmentId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "EnrollmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "enrollmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GradingPeriodEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT, `startDate` TEXT, `endDate` TEXT, `weight` REAL NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "GroupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT, `description` TEXT, `avatarUrl` TEXT, `isPublic` INTEGER NOT NULL, `membersCount` INTEGER NOT NULL, `joinLevel` TEXT, `courseId` INTEGER NOT NULL, `accountId` INTEGER NOT NULL, `role` TEXT, `groupCategoryId` INTEGER NOT NULL, `storageQuotaMb` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `concluded` INTEGER NOT NULL, `canAccess` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "membersCount", + "columnName": "membersCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "joinLevel", + "columnName": "joinLevel", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupCategoryId", + "columnName": "groupCategoryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageQuotaMb", + "columnName": "storageQuotaMb", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "concluded", + "columnName": "concluded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canAccess", + "columnName": "canAccess", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "GroupUserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `userId` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `GroupEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "GroupEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LocalFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, `createdDate` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdDate", + "columnName": "createdDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MasteryPathAssignmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `createdAt` TEXT, `updatedAt` TEXT, `overrideId` INTEGER NOT NULL, `assignmentSetId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`assignmentSetId`) REFERENCES `AssignmentSetEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "overrideId", + "columnName": "overrideId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentSetId", + "columnName": "assignmentSetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentSetEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentSetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MasteryPathEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `selectedSetId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `ModuleItemEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "selectedSetId", + "columnName": "selectedSetId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ModuleItemEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ModuleContentDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `pointsPossible` TEXT, `dueAt` TEXT, `unlockAt` TEXT, `lockAt` TEXT, `lockedForUser` INTEGER NOT NULL, `lockExplanation` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `ModuleItemEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pointsPossible", + "columnName": "pointsPossible", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockedForUser", + "columnName": "lockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockExplanation", + "columnName": "lockExplanation", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ModuleItemEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ModuleItemEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `moduleId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `title` TEXT, `indent` INTEGER NOT NULL, `type` TEXT, `htmlUrl` TEXT, `url` TEXT, `published` INTEGER, `contentId` INTEGER NOT NULL, `externalUrl` TEXT, `pageUrl` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`moduleId`) REFERENCES `ModuleObjectEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "moduleId", + "columnName": "moduleId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "indent", + "columnName": "indent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalUrl", + "columnName": "externalUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pageUrl", + "columnName": "pageUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ModuleObjectEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "moduleId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ModuleObjectEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `position` INTEGER NOT NULL, `name` TEXT, `unlockAt` TEXT, `sequentialProgress` INTEGER NOT NULL, `prerequisiteIds` TEXT, `state` TEXT, `completedAt` TEXT, `published` INTEGER, `itemCount` INTEGER NOT NULL, `itemsUrl` TEXT NOT NULL, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sequentialProgress", + "columnName": "sequentialProgress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prerequisiteIds", + "columnName": "prerequisiteIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemCount", + "columnName": "itemCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemsUrl", + "columnName": "itemsUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NeedsGradingCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sectionId` INTEGER NOT NULL, `needsGradingCount` INTEGER NOT NULL, PRIMARY KEY(`sectionId`), FOREIGN KEY(`sectionId`) REFERENCES `SectionEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sectionId", + "columnName": "sectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsGradingCount", + "columnName": "needsGradingCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sectionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "SectionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, `createdAt` INTEGER, `updatedAt` INTEGER, `hideFromStudents` INTEGER NOT NULL, `status` TEXT, `body` TEXT, `frontPage` INTEGER NOT NULL, `published` INTEGER NOT NULL, `editingRoles` TEXT, `htmlUrl` TEXT, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hideFromStudents", + "columnName": "hideFromStudents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "frontPage", + "columnName": "frontPage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editingRoles", + "columnName": "editingRoles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PlannerOverrideEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `plannableType` TEXT NOT NULL, `plannableId` INTEGER NOT NULL, `dismissed` INTEGER NOT NULL, `markedComplete` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "plannableType", + "columnName": "plannableType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "plannableId", + "columnName": "plannableId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dismissed", + "columnName": "dismissed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "markedComplete", + "columnName": "markedComplete", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `folderId` INTEGER NOT NULL, `displayName` TEXT, `fileName` TEXT, `contentType` TEXT, `url` TEXT, `size` INTEGER NOT NULL, `createdAt` TEXT, `updatedAt` TEXT, `unlockAt` TEXT, `locked` INTEGER NOT NULL, `hidden` INTEGER NOT NULL, `lockAt` TEXT, `hiddenForUser` INTEGER NOT NULL, `thumbnailUrl` TEXT, `modifiedAt` TEXT, `lockedForUser` INTEGER NOT NULL, `previewUrl` TEXT, `lockExplanation` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hiddenForUser", + "columnName": "hiddenForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "modifiedAt", + "columnName": "modifiedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockedForUser", + "columnName": "lockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockExplanation", + "columnName": "lockExplanation", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RubricCriterionAssessmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `assignmentId` INTEGER NOT NULL, `ratingId` TEXT, `points` REAL, `comments` TEXT, PRIMARY KEY(`id`, `assignmentId`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingId", + "columnName": "ratingId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "points", + "columnName": "points", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "comments", + "columnName": "comments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "assignmentId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RubricCriterionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT, `longDescription` TEXT, `points` REAL NOT NULL, `criterionUseRange` INTEGER NOT NULL, `ignoreForScoring` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "points", + "columnName": "points", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "criterionUseRange", + "columnName": "criterionUseRange", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ignoreForScoring", + "columnName": "ignoreForScoring", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RubricCriterionRatingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT, `longDescription` TEXT, `points` REAL NOT NULL, `rubricCriterionId` TEXT NOT NULL, PRIMARY KEY(`id`, `rubricCriterionId`), FOREIGN KEY(`rubricCriterionId`) REFERENCES `RubricCriterionEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "points", + "columnName": "points", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rubricCriterionId", + "columnName": "rubricCriterionId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "rubricCriterionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "RubricCriterionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "rubricCriterionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RubricSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `contextId` INTEGER NOT NULL, `contextType` TEXT, `pointsPossible` REAL NOT NULL, `title` TEXT NOT NULL, `isReusable` INTEGER NOT NULL, `isPublic` INTEGER NOT NULL, `isReadOnly` INTEGER NOT NULL, `freeFormCriterionComments` INTEGER NOT NULL, `hideScoreTotal` INTEGER NOT NULL, `hidePoints` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextId", + "columnName": "contextId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextType", + "columnName": "contextType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pointsPossible", + "columnName": "pointsPossible", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isReusable", + "columnName": "isReusable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReadOnly", + "columnName": "isReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "freeFormCriterionComments", + "columnName": "freeFormCriterionComments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hideScoreTotal", + "columnName": "hideScoreTotal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hidePoints", + "columnName": "hidePoints", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ScheduleItemAssignmentOverrideEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`assignmentOverrideId` INTEGER NOT NULL, `scheduleItemId` TEXT NOT NULL, PRIMARY KEY(`assignmentOverrideId`, `scheduleItemId`), FOREIGN KEY(`assignmentOverrideId`) REFERENCES `AssignmentOverrideEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`scheduleItemId`) REFERENCES `ScheduleItemEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assignmentOverrideId", + "columnName": "assignmentOverrideId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduleItemId", + "columnName": "scheduleItemId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "assignmentOverrideId", + "scheduleItemId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentOverrideEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentOverrideId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ScheduleItemEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "scheduleItemId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ScheduleItemEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `description` TEXT, `startAt` TEXT, `endAt` TEXT, `isAllDay` INTEGER NOT NULL, `allDayAt` TEXT, `locationAddress` TEXT, `locationName` TEXT, `htmlUrl` TEXT, `contextCode` TEXT, `effectiveContextCode` TEXT, `isHidden` INTEGER NOT NULL, `importantDates` INTEGER NOT NULL, `assignmentId` INTEGER, `type` TEXT NOT NULL, `itemType` TEXT, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startAt", + "columnName": "startAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endAt", + "columnName": "endAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isAllDay", + "columnName": "isAllDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allDayAt", + "columnName": "allDayAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locationAddress", + "columnName": "locationAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locationName", + "columnName": "locationName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contextCode", + "columnName": "contextCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "effectiveContextCode", + "columnName": "effectiveContextCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importantDates", + "columnName": "importantDates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemType", + "columnName": "itemType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SectionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `courseId` INTEGER, `startAt` TEXT, `endAt` TEXT, `totalStudents` INTEGER NOT NULL, `restrictEnrollmentsToSectionDates` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startAt", + "columnName": "startAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endAt", + "columnName": "endAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalStudents", + "columnName": "totalStudents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "restrictEnrollmentsToSectionDates", + "columnName": "restrictEnrollmentsToSectionDates", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SubmissionDiscussionEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`submissionId` INTEGER NOT NULL, `discussionEntryId` INTEGER NOT NULL, PRIMARY KEY(`submissionId`, `discussionEntryId`), FOREIGN KEY(`discussionEntryId`) REFERENCES `DiscussionEntryEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discussionEntryId", + "columnName": "discussionEntryId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "submissionId", + "discussionEntryId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionEntryEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "discussionEntryId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SubmissionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `grade` TEXT, `score` REAL NOT NULL, `attempt` INTEGER NOT NULL, `submittedAt` INTEGER, `commentCreated` INTEGER, `mediaContentType` TEXT, `mediaCommentUrl` TEXT, `mediaCommentDisplay` TEXT, `body` TEXT, `isGradeMatchesCurrentSubmission` INTEGER NOT NULL, `workflowState` TEXT, `submissionType` TEXT, `previewUrl` TEXT, `url` TEXT, `late` INTEGER NOT NULL, `excused` INTEGER NOT NULL, `missing` INTEGER NOT NULL, `mediaCommentId` TEXT, `assignmentId` INTEGER NOT NULL, `userId` INTEGER, `graderId` INTEGER, `groupId` INTEGER, `pointsDeducted` REAL, `enteredScore` REAL NOT NULL, `enteredGrade` TEXT, `postedAt` INTEGER, `gradingPeriodId` INTEGER, PRIMARY KEY(`id`, `attempt`), FOREIGN KEY(`groupId`) REFERENCES `GroupEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "grade", + "columnName": "grade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "attempt", + "columnName": "attempt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "submittedAt", + "columnName": "submittedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "commentCreated", + "columnName": "commentCreated", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mediaContentType", + "columnName": "mediaContentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaCommentUrl", + "columnName": "mediaCommentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaCommentDisplay", + "columnName": "mediaCommentDisplay", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isGradeMatchesCurrentSubmission", + "columnName": "isGradeMatchesCurrentSubmission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workflowState", + "columnName": "workflowState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "submissionType", + "columnName": "submissionType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "late", + "columnName": "late", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "excused", + "columnName": "excused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "missing", + "columnName": "missing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaCommentId", + "columnName": "mediaCommentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "graderId", + "columnName": "graderId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pointsDeducted", + "columnName": "pointsDeducted", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "enteredScore", + "columnName": "enteredScore", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "enteredGrade", + "columnName": "enteredGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "postedAt", + "columnName": "postedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "gradingPeriodId", + "columnName": "gradingPeriodId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "attempt" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "GroupEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UserEntity", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SyncSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `autoSyncEnabled` INTEGER NOT NULL, `syncFrequency` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoSyncEnabled", + "columnName": "autoSyncEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncFrequency", + "columnName": "syncFrequency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TabEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `label` TEXT, `type` TEXT NOT NULL, `htmlUrl` TEXT, `externalUrl` TEXT, `visibility` TEXT NOT NULL, `isHidden` INTEGER NOT NULL, `position` INTEGER NOT NULL, `ltiUrl` TEXT NOT NULL, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`, `courseId`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalUrl", + "columnName": "externalUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ltiUrl", + "columnName": "ltiUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "courseId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "TermEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT, `startAt` TEXT, `endAt` TEXT, `isGroupTerm` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startAt", + "columnName": "startAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endAt", + "columnName": "endAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isGroupTerm", + "columnName": "isGroupTerm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UserCalendarEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ics` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ics", + "columnName": "ics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `shortName` TEXT, `loginId` TEXT, `avatarUrl` TEXT, `primaryEmail` TEXT, `email` TEXT, `sortableName` TEXT, `bio` TEXT, `enrollmentIndex` INTEGER NOT NULL, `lastLogin` TEXT, `locale` TEXT, `effective_locale` TEXT, `pronouns` TEXT, `k5User` INTEGER NOT NULL, `rootAccount` TEXT, `isFakeStudent` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "primaryEmail", + "columnName": "primaryEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sortableName", + "columnName": "sortableName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentIndex", + "columnName": "enrollmentIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastLogin", + "columnName": "lastLogin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "effective_locale", + "columnName": "effective_locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "k5User", + "columnName": "k5User", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rootAccount", + "columnName": "rootAccount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isFakeStudent", + "columnName": "isFakeStudent", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "QuizEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT, `mobileUrl` TEXT, `htmlUrl` TEXT, `description` TEXT, `quizType` TEXT, `assignmentGroupId` INTEGER NOT NULL, `allowedAttempts` INTEGER NOT NULL, `questionCount` INTEGER NOT NULL, `pointsPossible` TEXT, `isLockQuestionsAfterAnswering` INTEGER NOT NULL, `dueAt` TEXT, `timeLimit` INTEGER NOT NULL, `shuffleAnswers` INTEGER NOT NULL, `showCorrectAnswers` INTEGER NOT NULL, `scoringPolicy` TEXT, `accessCode` TEXT, `ipFilter` TEXT, `lockedForUser` INTEGER NOT NULL, `lockExplanation` TEXT, `hideResults` TEXT, `showCorrectAnswersAt` TEXT, `hideCorrectAnswersAt` TEXT, `unlockAt` TEXT, `oneTimeResults` INTEGER NOT NULL, `lockAt` TEXT, `questionTypes` TEXT NOT NULL, `hasAccessCode` INTEGER NOT NULL, `oneQuestionAtATime` INTEGER NOT NULL, `requireLockdownBrowser` INTEGER NOT NULL, `requireLockdownBrowserForResults` INTEGER NOT NULL, `allowAnonymousSubmissions` INTEGER NOT NULL, `published` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `isOnlyVisibleToOverrides` INTEGER NOT NULL, `unpublishable` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mobileUrl", + "columnName": "mobileUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quizType", + "columnName": "quizType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "assignmentGroupId", + "columnName": "assignmentGroupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowedAttempts", + "columnName": "allowedAttempts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "questionCount", + "columnName": "questionCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pointsPossible", + "columnName": "pointsPossible", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isLockQuestionsAfterAnswering", + "columnName": "isLockQuestionsAfterAnswering", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timeLimit", + "columnName": "timeLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shuffleAnswers", + "columnName": "shuffleAnswers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showCorrectAnswers", + "columnName": "showCorrectAnswers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scoringPolicy", + "columnName": "scoringPolicy", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accessCode", + "columnName": "accessCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ipFilter", + "columnName": "ipFilter", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockedForUser", + "columnName": "lockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockExplanation", + "columnName": "lockExplanation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideResults", + "columnName": "hideResults", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showCorrectAnswersAt", + "columnName": "showCorrectAnswersAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideCorrectAnswersAt", + "columnName": "hideCorrectAnswersAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTimeResults", + "columnName": "oneTimeResults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "questionTypes", + "columnName": "questionTypes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasAccessCode", + "columnName": "hasAccessCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "oneQuestionAtATime", + "columnName": "oneQuestionAtATime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requireLockdownBrowser", + "columnName": "requireLockdownBrowser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requireLockdownBrowserForResults", + "columnName": "requireLockdownBrowserForResults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowAnonymousSubmissions", + "columnName": "allowAnonymousSubmissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isOnlyVisibleToOverrides", + "columnName": "isOnlyVisibleToOverrides", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unpublishable", + "columnName": "unpublishable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LockInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `modulePrerequisiteNames` TEXT, `unlockAt` TEXT, `lockedModuleId` INTEGER, `assignmentId` INTEGER, `moduleId` INTEGER, `pageId` INTEGER, FOREIGN KEY(`moduleId`) REFERENCES `ModuleContentDetailsEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`pageId`) REFERENCES `PageEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modulePrerequisiteNames", + "columnName": "modulePrerequisiteNames", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockedModuleId", + "columnName": "lockedModuleId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "moduleId", + "columnName": "moduleId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ModuleContentDetailsEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "moduleId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "PageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LockedModuleEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contextId` INTEGER NOT NULL, `contextType` TEXT, `name` TEXT, `unlockAt` TEXT, `isRequireSequentialProgress` INTEGER NOT NULL, `lockInfoId` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`lockInfoId`) REFERENCES `LockInfoEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextId", + "columnName": "contextId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextType", + "columnName": "contextType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRequireSequentialProgress", + "columnName": "isRequireSequentialProgress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockInfoId", + "columnName": "lockInfoId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LockInfoEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "lockInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ModuleNameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `lockedModuleId` INTEGER NOT NULL, FOREIGN KEY(`lockedModuleId`) REFERENCES `LockedModuleEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockedModuleId", + "columnName": "lockedModuleId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LockedModuleEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "lockedModuleId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ModuleCompletionRequirementEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT, `minScore` REAL NOT NULL, `maxScore` REAL NOT NULL, `completed` INTEGER, `moduleId` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "minScore", + "columnName": "minScore", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "maxScore", + "columnName": "maxScore", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "completed", + "columnName": "completed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "moduleId", + "columnName": "moduleId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FileSyncSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `fileName` TEXT, `courseId` INTEGER NOT NULL, `url` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseSyncSettingsEntity`(`courseId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseSyncSettingsEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "courseId" + ] + } + ] + }, + { + "tableName": "ConferenceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, `conferenceKey` TEXT, `conferenceType` TEXT, `description` TEXT, `duration` INTEGER NOT NULL, `endedAt` INTEGER, `hasAdvancedSettings` INTEGER NOT NULL, `joinUrl` TEXT, `longRunning` INTEGER NOT NULL, `startedAt` INTEGER, `title` TEXT, `url` TEXT, `contextType` TEXT NOT NULL, `contextId` INTEGER NOT NULL, `record` INTEGER, `users` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conferenceKey", + "columnName": "conferenceKey", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "conferenceType", + "columnName": "conferenceType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasAdvancedSettings", + "columnName": "hasAdvancedSettings", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "joinUrl", + "columnName": "joinUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "longRunning", + "columnName": "longRunning", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contextType", + "columnName": "contextType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextId", + "columnName": "contextId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record", + "columnName": "record", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "users", + "columnName": "users", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ConferenceRecordingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordingId` TEXT NOT NULL, `conferenceId` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `durationMinutes` INTEGER NOT NULL, `playbackUrl` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`recordingId`), FOREIGN KEY(`conferenceId`) REFERENCES `ConferenceEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordingId", + "columnName": "recordingId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conferenceId", + "columnName": "conferenceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationMinutes", + "columnName": "durationMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playbackUrl", + "columnName": "playbackUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordingId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ConferenceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "conferenceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "CourseFeaturesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `features` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "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`), FOREIGN KEY(`submissionCommentId`) REFERENCES `SubmissionCommentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "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": [ + { + "table": "SubmissionCommentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "submissionCommentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MediaCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` TEXT NOT NULL, `submissionId` INTEGER NOT NULL, `attemptId` INTEGER NOT NULL, `displayName` TEXT, `url` TEXT, `mediaType` TEXT, `contentType` TEXT, PRIMARY KEY(`mediaId`), FOREIGN KEY(`submissionId`, `attemptId`) REFERENCES `SubmissionEntity`(`id`, `attempt`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "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": [ + { + "table": "SubmissionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "submissionId", + "attemptId" + ], + "referencedColumns": [ + "id", + "attempt" + ] + } + ] + }, + { + "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": "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`), FOREIGN KEY(`submissionId`, `attemptId`) REFERENCES `SubmissionEntity`(`id`, `attempt`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "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": [ + { + "table": "SubmissionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "submissionId", + "attemptId" + ], + "referencedColumns": [ + "id", + "attempt" + ] + } + ] + }, + { + "tableName": "DiscussionTopicEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `unreadEntries` TEXT NOT NULL, `participantIds` TEXT NOT NULL, `viewIds` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadEntries", + "columnName": "unreadEntries", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantIds", + "columnName": "participantIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "viewIds", + "columnName": "viewIds", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "CourseSyncProgressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `courseName` TEXT NOT NULL, `tabs` TEXT NOT NULL, `additionalFilesStarted` INTEGER NOT NULL, `progressState` TEXT NOT NULL, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseName", + "columnName": "courseName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabs", + "columnName": "tabs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "additionalFilesStarted", + "columnName": "additionalFilesStarted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressState", + "columnName": "progressState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FileSyncProgressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, `fileName` TEXT NOT NULL, `progress` INTEGER NOT NULL, `fileSize` INTEGER NOT NULL, `additionalFile` INTEGER NOT NULL, `progressState` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`courseId`) REFERENCES `CourseSyncProgressEntity`(`courseId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "additionalFile", + "columnName": "additionalFile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressState", + "columnName": "progressState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseSyncProgressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "courseId" + ] + } + ] + } + ], + "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, '1182046fc85ec1b16d7693e1a593081b')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/ProgressScreenTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/ProgressScreenTest.kt new file mode 100644 index 0000000000..e43574c14f --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/ProgressScreenTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2024 - 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.compose + +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.pandautils.R +import com.instructure.pandautils.features.progress.ProgressState +import com.instructure.pandautils.features.progress.ProgressUiState +import com.instructure.pandautils.features.progress.composables.ProgressScreen +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ProgressScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun assertRunningState() { + composeTestRule.setContent { + ProgressScreen( + progressUiState = ProgressUiState( + stringResource(id = R.string.allModulesAndItems), + stringResource(id = R.string.publishing), + 10f, + "Bulk Update Note", + ProgressState.RUNNING + ) + ) { + // No-op + } + } + + composeTestRule.onNodeWithText("All Modules and Items").assertIsDisplayed() + composeTestRule.onNodeWithText("Publishing 10%").assertIsDisplayed() + composeTestRule.onNodeWithText("Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Bulk Update Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").assertIsDisplayed() + composeTestRule.onNodeWithText("Done").assertDoesNotExist() + } + + @Test + fun assertCompletedState() { + composeTestRule.setContent { + ProgressScreen( + progressUiState = ProgressUiState( + stringResource(id = R.string.allModules), + stringResource(id = R.string.publishing), + 100f, + "Bulk Update Note", + ProgressState.COMPLETED + ) + ) { + // No-op + } + } + + composeTestRule.onNodeWithText("All Modules").assertIsDisplayed() + composeTestRule.onNodeWithText("Success!").assertIsDisplayed() + composeTestRule.onNodeWithText("Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Bulk Update Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Done").assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").assertDoesNotExist() + } + + @Test + fun assertFailedState() { + composeTestRule.setContent { + ProgressScreen( + progressUiState = ProgressUiState( + stringResource(id = R.string.selectedModulesAndItems), + stringResource(id = R.string.publishing), + 10f, + "Bulk Update Note", + ProgressState.FAILED + ) + ) { + // No-op + } + } + + composeTestRule.onNodeWithText("Selected Modules and Items").assertIsDisplayed() + composeTestRule.onNodeWithText("Update failed").assertIsDisplayed() + composeTestRule.onNodeWithText("Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Bulk Update Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Done").assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").assertDoesNotExist() + } + + @Test + fun assertNoteNotDisplayed() { + composeTestRule.setContent { + ProgressScreen( + progressUiState = ProgressUiState( + stringResource(id = R.string.allModulesAndItems), + stringResource(id = R.string.publishing), + 10f, + null, + ProgressState.RUNNING + ) + ) { + // No-op + } + } + + composeTestRule.onNodeWithText("Note").assertDoesNotExist() + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt new file mode 100644 index 0000000000..9b39bfbe6b --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2024 - 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.pandautils.room.appdatabase.daos + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +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.ReminderEntity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.* + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class ReminderDaoTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var db: AppDatabase + private lateinit var reminderDao: ReminderDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + reminderDao = db.reminderDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testFindItemsByAssignmentId() = runTest { + val entities = listOf( + ReminderEntity(1, 1, 1, "htmlUrl1", "Assignment 1", "1 day", 1000), + ReminderEntity(2, 2, 1, "htmlUrl2", "Assignment 2", "2 days", 2000), + ReminderEntity(3, 1, 2, "htmlUrl3", "Assignment 3", "3 days", 3000) + ) + entities.forEach { reminderDao.insert(it) } + + val result = reminderDao.findByAssignmentIdLiveData(1, 1) + result.observeForever { } + + Assert.assertEquals(entities.take(1), result.value) + } + + @Test + fun testDeleteById() = runTest { + val entities = listOf( + ReminderEntity(1, 1, 1, "htmlUrl1", "Assignment 1", "1 day", 1000), + ReminderEntity(2, 1, 1, "htmlUrl2", "Assignment 2", "2 days", 2000), + ) + entities.forEach { reminderDao.insert(it) } + + reminderDao.deleteById(1) + + val result = reminderDao.findByAssignmentIdLiveData(1, 1) + result.observeForever { } + + Assert.assertEquals(entities.takeLast(1), result.value) + } + + @Test + fun testDeletePastReminders() = runTest { + val entities = listOf( + ReminderEntity(1, 1, 1, "htmlUrl1", "Assignment 1", "1 day", 1000), + ReminderEntity(2, 1, 1, "htmlUrl2", "Assignment 2", "2 days", 2000), + ReminderEntity(2, 1, 1, "htmlUrl2", "Assignment 2", "2 days", 3000) + ) + entities.forEach { reminderDao.insert(it) } + + reminderDao.deletePastReminders(2000) + + val result = reminderDao.findByAssignmentIdLiveData(1, 1) + result.observeForever { } + + Assert.assertEquals(entities.takeLast(1), result.value) + } + + @Test + fun testFindItemsByUserId() = runTest { + val entities = listOf( + ReminderEntity(1, 1, 1, "htmlUrl1", "Assignment 1", "1 day", 1000), + ReminderEntity(2, 1, 3, "htmlUrl2", "Assignment 2", "2 days", 2000), + ReminderEntity(3, 2, 3, "htmlUrl3", "Assignment 3", "3 days", 3000) + ) + entities.forEach { reminderDao.insert(it) } + + val result = reminderDao.findByUserId(1) + + Assert.assertEquals(entities.take(2), result) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/binding/BindingAdapters.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/binding/BindingAdapters.kt index 4db7dad41e..c83f680874 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/binding/BindingAdapters.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/binding/BindingAdapters.kt @@ -99,7 +99,7 @@ private fun handleErrorState(emptyView: EmptyView, error: ViewState.Error) { emptyView.setGone() } else { emptyView.setVisible() - emptyView.setError(error.errorMessage) + emptyView.setError(error.errorMessage, error.errorImage) } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/binding/ViewBindingDelegate.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/binding/ViewBindingDelegate.kt index f81c2f2d34..991fd5c913 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/binding/ViewBindingDelegate.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/binding/ViewBindingDelegate.kt @@ -22,6 +22,7 @@ import android.view.LayoutInflater import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -84,3 +85,9 @@ inline fun AppCompatActivity.viewBinding( lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) } + +inline fun FragmentActivity.viewBinding( + crossinline bindingInflater: (LayoutInflater) -> T) = + lazy(LazyThreadSafetyMode.NONE) { + bindingInflater.invoke(layoutInflater) + } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt new file mode 100644 index 0000000000..a427dd6966 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2024 - 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.compose + +import android.content.Context +import androidx.annotation.FontRes +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Typography +import androidx.compose.material.ripple.LocalRippleTheme +import androidx.compose.material.ripple.RippleAlpha +import androidx.compose.material.ripple.RippleTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import com.instructure.pandautils.R + +@Composable +fun CanvasTheme(content: @Composable () -> Unit) { + MaterialTheme( + typography = typography.copy( + button = typography.button.copy(letterSpacing = TextUnit(0.5f, TextUnitType.Sp)) + ) + ) { + CompositionLocalProvider( + LocalRippleTheme provides CanvasRippleTheme, + LocalTextSelectionColors provides getCustomTextSelectionColors(context = LocalContext.current), + LocalTextStyle provides TextStyle( + fontFamily = lato, + letterSpacing = TextUnit(0f, TextUnitType.Sp) + ), + content = content + ) + } +} + +private val lato = FontFamily( + Font(R.font.lato_regular) +) + +private var typography = Typography( + defaultFontFamily = lato, +) + +fun overrideComposeFonts(@FontRes fontResource: Int) { + val newFont = FontFamily( + Font(fontResource) + ) + + typography = Typography( + defaultFontFamily = newFont, + ) +} + +private object CanvasRippleTheme : RippleTheme { + @Composable + override fun defaultColor(): Color = colorResource(id = R.color.backgroundDark) + + @Composable + override fun rippleAlpha(): RippleAlpha = RippleTheme.defaultRippleAlpha( + Color.Black, + lightTheme = !isSystemInDarkTheme() + ) +} + +private fun getCustomTextSelectionColors(context: Context): TextSelectionColors { + val color = Color(context.getColor(R.color.textDarkest)) + return TextSelectionColors( + handleColor = color, + backgroundColor = color.copy(alpha = 0.4f) + ) +} \ 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 c4890d639e..8e9679b912 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 @@ -59,4 +59,10 @@ class DatabaseModule { fun provideEnvironmentFeatureFlagsDao(appDatabase: AppDatabase): EnvironmentFeatureFlagsDao { return appDatabase.environmentFeatureFlagsDao() } + + @Provides + @Singleton + fun provideReminderDao(appDatabase: AppDatabase): ReminderDao { + return appDatabase.reminderDao() + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/ProgressModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/ProgressModule.kt new file mode 100644 index 0000000000..d28a0978f2 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/ProgressModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 - 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.di + +import com.instructure.pandautils.features.progress.ProgressPreferences +import com.instructure.pandautils.room.appdatabase.AppDatabase +import com.instructure.pandautils.room.appdatabase.daos.ModuleBulkProgressDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class ProgressModule { + + @Provides + @Singleton + fun provideProgressPreferences(): ProgressPreferences { + return ProgressPreferences + } + + @Provides + @Singleton + fun provideModuleBulkProgressDao(appDatabase: AppDatabase): ModuleBulkProgressDao { + return appDatabase.moduleBulkProgressDao() + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/DatePickerDialogFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/DatePickerDialogFragment.kt index d05898c8f1..7f88d0a4ce 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/DatePickerDialogFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/DatePickerDialogFragment.kt @@ -25,6 +25,7 @@ import androidx.appcompat.app.AppCompatDialogFragment import androidx.fragment.app.FragmentManager import com.instructure.pandautils.analytics.SCREEN_VIEW_DATE_PICKER import com.instructure.pandautils.analytics.ScreenView +import com.instructure.pandautils.utils.NullableSerializableArg import com.instructure.pandautils.utils.SerializableArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.dismissExisting @@ -34,19 +35,23 @@ import kotlin.properties.Delegates @ScreenView(SCREEN_VIEW_DATE_PICKER) class DatePickerDialogFragment : AppCompatDialogFragment(), DatePickerDialog.OnDateSetListener { - var mCallback: (year: Int, month: Int, dayOfMonth: Int) -> Unit by Delegates.notNull() - var mDefaultDate by SerializableArg(Date()) + var callback: (year: Int, month: Int, dayOfMonth: Int) -> Unit by Delegates.notNull() + var defaultDate by SerializableArg(Date()) + var minDate by NullableSerializableArg() + var maxDate by NullableSerializableArg() override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { // Setup default date val c = Calendar.getInstance() - c.time = mDefaultDate + c.time = defaultDate val year = c.get(Calendar.YEAR) val month = c.get(Calendar.MONTH) val day = c.get(Calendar.DAY_OF_MONTH) val dialog = DatePickerDialog(requireContext(), this, year, month, day) + minDate?.let { dialog.datePicker.minDate = it.time } + maxDate?.let { dialog.datePicker.maxDate = it.time } dialog.setOnShowListener { dialog.getButton(AppCompatDialog.BUTTON_POSITIVE).setTextColor(ThemePrefs.textButtonColor) @@ -57,15 +62,17 @@ class DatePickerDialogFragment : AppCompatDialogFragment(), DatePickerDialog.OnD } override fun onDateSet(view: DatePicker?, year: Int, month: Int, dayOfMonth: Int) { - mCallback(year, month, dayOfMonth) + callback(year, month, dayOfMonth) } companion object { - fun getInstance(manager: FragmentManager, defaultDate: Date? = null, callback: (Int, Int, Int) -> Unit) : DatePickerDialogFragment { + fun getInstance(manager: FragmentManager, defaultDate: Date? = null, minDate: Date? = null, maxDate: Date? = null, callback: (Int, Int, Int) -> Unit) : DatePickerDialogFragment { manager.dismissExisting() val dialog = DatePickerDialogFragment() - dialog.mCallback = callback - defaultDate?.let { dialog.mDefaultDate = it } + dialog.callback = callback + defaultDate?.let { dialog.defaultDate = it } + minDate?.let { dialog.minDate = it } + maxDate?.let { dialog.maxDate = it } return dialog } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt index c402282c08..ac94862493 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt @@ -75,7 +75,7 @@ class DiscussionDetailsWebViewFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.data.observe(viewLifecycleOwner) { - applyTheme(it.title) + setupToolbar(it.title) } setupFilePicker() binding.discussionWebView.addVideoClient(requireActivity()) @@ -91,7 +91,6 @@ class DiscussionDetailsWebViewFragment : Fragment() { override fun onPageFinishedCallback(webView: WebView, url: String) { viewModel.setLoading(false) - binding.discussionSwipeRefreshLayout?.isRefreshing = false } override fun routeInternallyCallback(url: String) { @@ -104,10 +103,6 @@ class DiscussionDetailsWebViewFragment : Fragment() { return viewModel.data.value?.url?.substringBefore("?") != url.substringBefore("?") } } - - binding.discussionSwipeRefreshLayout.setOnRefreshListener { - binding.discussionWebView.reload() - } } private fun setupFilePicker() { @@ -148,9 +143,14 @@ class DiscussionDetailsWebViewFragment : Fragment() { } } - private fun applyTheme(title: String) = with(binding) { + private fun setupToolbar(title: String) = with(binding) { toolbar.title = title toolbar.setupAsBackButton(this@DiscussionDetailsWebViewFragment) + binding.toolbar.setMenu(R.menu.menu_discussion_details) { + when (it.itemId) { + R.id.refresh -> binding.discussionWebView.reload() + } + } ViewStyler.themeToolbarColored(requireActivity(), toolbar, canvasContext) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/router/DiscussionRouteHelperNetworkDataSource.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/router/DiscussionRouteHelperNetworkDataSource.kt index 69aecf616e..fc6460487a 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/router/DiscussionRouteHelperNetworkDataSource.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/router/DiscussionRouteHelperNetworkDataSource.kt @@ -40,14 +40,14 @@ class DiscussionRouteHelperNetworkDataSource( val params = RestParams(isForceReadFromNetwork = forceNetwork) return if (canvasContext.isCourse) { val featureFlags = featuresApi.getEnabledFeaturesForCourse(canvasContext.id, params).dataOrNull - featureFlags?.contains("react_discussions_post") ?: false && featureFlagProvider.getDiscussionRedesignFeatureFlag() + featureFlags?.contains("react_discussions_post") ?: false } else if (canvasContext.isGroup) { val group = canvasContext as Group if (group.courseId == 0L) { featureFlagProvider.getDiscussionRedesignFeatureFlag() } else { val featureFlags = featuresApi.getEnabledFeaturesForCourse(group.courseId, params).dataOrNull - featureFlags?.contains("react_discussions_post") ?: false && featureFlagProvider.getDiscussionRedesignFeatureFlag() + featureFlags?.contains("react_discussions_post") ?: false } } else { false diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/NotificationPreferencesViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/NotificationPreferencesViewModel.kt index 31388183c0..aa51da896f 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/NotificationPreferencesViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/NotificationPreferencesViewModel.kt @@ -101,7 +101,7 @@ abstract class NotificationPreferencesViewModel ( val categories = hashMapOf>() - for ((categoryName, prefs) in items.groupBy { it.category }) { + for ((categoryName, prefs) in items.filterNotificationPreferences().groupBy { it.category }) { val categoryHelper = categoryHelperMap[categoryName] ?: continue val header = groupHeaderMap[categoryHelper.categoryGroup] ?: continue @@ -131,4 +131,6 @@ abstract class NotificationPreferencesViewModel ( } abstract fun createCategoryItemViewModel(viewData: NotificationCategoryViewData): NotificationCategoryItemViewModel + + open fun List.filterNotificationPreferences(): List = this } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModel.kt index ab9603eedc..5ded7c0b6d 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModel.kt @@ -20,8 +20,10 @@ import android.content.res.Resources import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.managers.CommunicationChannelsManager import com.instructure.canvasapi2.managers.NotificationPreferencesFrequency -import com.instructure.canvasapi2.managers.NotificationPreferencesFrequency.* +import com.instructure.canvasapi2.managers.NotificationPreferencesFrequency.IMMEDIATELY +import com.instructure.canvasapi2.managers.NotificationPreferencesFrequency.NEVER import com.instructure.canvasapi2.managers.NotificationPreferencesManager +import com.instructure.canvasapi2.models.NotificationPreference import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.pandautils.BR import com.instructure.pandautils.R @@ -48,6 +50,8 @@ class PushNotificationPreferencesViewModel @Inject constructor( return PushNotificationCategoryItemViewModel(viewData, ::toggleNotification) } + override fun List.filterNotificationPreferences() = filter { it.category in ALLOWED_PUSH_NOTIFICATIONS } + private fun toggleNotification(enabled: Boolean, categoryName: String) { viewModelScope.launch { try { @@ -81,4 +85,23 @@ class PushNotificationPreferencesViewModel @Inject constructor( private val Boolean.frequency: NotificationPreferencesFrequency get() = if (this) IMMEDIATELY else NEVER + companion object { + private val ALLOWED_PUSH_NOTIFICATIONS = listOf( + "announcement", + "appointment_availability", + "appointment_cancelations", + "calendar", + "conversation_message", + "course_content", + "discussion_mention", + "reported_reply", + "due_date", + "grading", + "invitation", + "student_appointment_signups", + "submission_comment", + "discussion", + "discussion_entry" + ) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressDialogFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressDialogFragment.kt new file mode 100644 index 0000000000..0fe9cd0fd2 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressDialogFragment.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2024 - 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.progress + +import android.app.Dialog +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.instructure.pandautils.features.progress.composables.ProgressScreen +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@AndroidEntryPoint +class ProgressDialogFragment : BottomSheetDialogFragment() { + + private val viewModel: ProgressViewModel by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return ComposeView(requireContext()).apply { + setContent { + val uiState by viewModel.uiState.collectAsState() + ProgressScreen(uiState, viewModel::handleAction) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + lifecycleScope.launch { + withContext(Dispatchers.Main.immediate) { + viewModel.events.collect { action -> + when (action) { + is ProgressViewModelAction.Close -> dismiss() + } + } + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + setOnShowListener { + val bottomSheet = findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + val behavior = BottomSheetBehavior.from(bottomSheet) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.skipCollapsed = true + } + } + } + + companion object { + + const val PROGRESS_ID = "progressId" + const val TITLE = "title" + const val PROGRESS_TITLE = "progressTitle" + const val NOTE = "note" + + fun newInstance(progressId: Long, title: String, progressTitle: String, note: String? = null) = + ProgressDialogFragment().apply { + arguments = Bundle().apply { + putLong(PROGRESS_ID, progressId) + putString(TITLE, title) + putString(PROGRESS_TITLE, progressTitle) + note?.let { putString(NOTE, it) } + } + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressPreferences.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressPreferences.kt new file mode 100644 index 0000000000..9518f8c4d1 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressPreferences.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 - 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.progress + +import com.instructure.canvasapi2.utils.PrefManager +import com.instructure.canvasapi2.utils.SetPref + +object ProgressPreferences : PrefManager("progress-preferences") { + + var cancelledProgressIds by SetPref(Long::class) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressUiState.kt new file mode 100644 index 0000000000..7881113127 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressUiState.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 - 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.progress + +data class ProgressUiState( + val title: String, + val progressTitle: String, + val progress: Float, + val note: String?, + val state: ProgressState, +) + +enum class ProgressState { + QUEUED, + RUNNING, + COMPLETED, + FAILED +} + +sealed class ProgressAction { + object Cancel : ProgressAction() + object Close : ProgressAction() +} + +sealed class ProgressViewModelAction { + object Close : ProgressViewModelAction() +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressViewModel.kt new file mode 100644 index 0000000000..3cddb37460 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressViewModel.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2024 - 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.progress + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.apis.ProgressAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.pandautils.utils.poll +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ProgressViewModel @Inject constructor( + stateHandle: SavedStateHandle, + private val progressApi: ProgressAPI.ProgressInterface, + private val progressPreferences: ProgressPreferences +) : ViewModel() { + + private val progressId = stateHandle.get(ProgressDialogFragment.PROGRESS_ID) ?: -1 + private val title = stateHandle.get(ProgressDialogFragment.TITLE) ?: "" + private val progressTitle = stateHandle.get(ProgressDialogFragment.PROGRESS_TITLE) ?: "" + private val note = stateHandle.get(ProgressDialogFragment.NOTE) + + private val _uiState = MutableStateFlow( + ProgressUiState( + title = title, + progressTitle = progressTitle, + progress = 0f, + note = note, + state = ProgressState.QUEUED, + ) + ) + val uiState = _uiState.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + init { + viewModelScope.launch { + loadData() + } + } + + private suspend fun loadData() { + val params = RestParams(isForceReadFromNetwork = true) + try { + poll(500, -1, block = { + val progress = progressApi.getProgress(progressId.toString(), params).dataOrThrow + val newState = when { + progress.isQueued -> ProgressState.QUEUED + progress.isRunning -> ProgressState.RUNNING + progress.isCompleted -> ProgressState.COMPLETED + progress.isFailed -> ProgressState.FAILED + else -> ProgressState.QUEUED + } + _uiState.emit(_uiState.value.copy(progress = progress.completion, state = newState)) + progress + }, + validate = { + it.hasRun + }) + } catch (e: Exception) { + _uiState.emit(_uiState.value.copy(state = ProgressState.FAILED)) + } + } + + fun handleAction(action: ProgressAction) { + viewModelScope.launch { + when (action) { + is ProgressAction.Cancel -> { + cancel() + } + + is ProgressAction.Close -> { + _events.send(ProgressViewModelAction.Close) + } + } + } + } + + private suspend fun cancel() { + progressPreferences.cancelledProgressIds = progressPreferences.cancelledProgressIds + progressId + val params = RestParams(isForceReadFromNetwork = true) + progressApi.cancelProgress(progressId.toString(), params).dataOrNull + _events.send(ProgressViewModelAction.Close) + } + +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/composables/ProgressScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/composables/ProgressScreen.kt new file mode 100644 index 0000000000..aa0b43c28f --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/composables/ProgressScreen.kt @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2024 - 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.progress.composables + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.instructure.canvasapi2.models.CanvasTheme +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.features.progress.ProgressState +import com.instructure.pandautils.features.progress.ProgressUiState +import com.instructure.pandautils.features.progress.ProgressAction +import kotlin.math.roundToInt + +@Composable +fun ProgressScreen( + progressUiState: ProgressUiState, + actionHandler: (ProgressAction) -> Unit +) { + CanvasTheme { + Scaffold( + modifier = Modifier.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)), + backgroundColor = colorResource(id = R.color.backgroundLightest), + topBar = { + ProgressTopBar( + title = progressUiState.title, + state = progressUiState.state, + actionHandler = actionHandler + ) + } + ) { padding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(padding), + color = colorResource(id = R.color.backgroundLightest) + ) { + ProgressContent( + progressTitle = progressUiState.progressTitle, + progress = progressUiState.progress, + note = progressUiState.note, + state = progressUiState.state + ) + } + } + } +} + +@Composable +fun ProgressTopBar( + modifier: Modifier = Modifier, + title: String, + state: ProgressState, + actionHandler: (ProgressAction) -> Unit +) { + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { actionHandler(ProgressAction.Close) }) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(id = R.string.a11y_closeProgress), + tint = colorResource(id = R.color.textDarkest) + ) + } + Text( + text = title, + fontSize = 16.sp, + color = colorResource(id = R.color.textDarkest), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.weight(1f)) + if (state == ProgressState.COMPLETED || state == ProgressState.FAILED) { + TextButton( + modifier = Modifier.padding(end = 12.dp), + onClick = { actionHandler(ProgressAction.Close) }) { + Text(text = stringResource(id = R.string.done), color = colorResource(id = R.color.textDarkest)) + } + } else { + TextButton( + modifier = Modifier.padding(end = 12.dp), + onClick = { actionHandler(ProgressAction.Cancel) }) { + Text(text = stringResource(id = R.string.cancel), color = colorResource(id = R.color.textDarkest)) + } + } + } + Divider(color = colorResource(id = R.color.backgroundMedium)) + } + +} + +@Composable +fun ProgressContent( + modifier: Modifier = Modifier, + progressTitle: String, + progress: Float, + note: String?, + state: ProgressState +) { + Column(modifier = modifier) { + ProgressIndicator( + modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp), + progressTitle = progressTitle, + progress = progress, + state = state + ) + Divider(color = colorResource(id = R.color.backgroundMedium)) + Text( + text = stringResource(R.string.progressMessage), + modifier = Modifier.padding(16.dp), + color = colorResource(id = R.color.textDarkest) + ) + note?.let { + Note(modifier = Modifier.padding(16.dp), note = it) + } + } +} + +@Composable +fun Note(modifier: Modifier = Modifier, note: String) { + Column(modifier = modifier.verticalScroll(rememberScrollState())) { + Text( + text = stringResource(id = R.string.noteTitle), + color = colorResource(id = R.color.textDark), + fontSize = 14.sp + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = note, + color = colorResource(id = R.color.textDarkest), + fontSize = 16.sp + ) + + } +} + +@Composable +fun ProgressIndicator(modifier: Modifier = Modifier, progressTitle: String, progress: Float, state: ProgressState) { + val title = when (state) { + ProgressState.COMPLETED -> stringResource(id = R.string.success) + ProgressState.FAILED -> stringResource(id = R.string.updateFailed) + else -> "$progressTitle ${progress.roundToInt()}%" + } + val progressColor = when (state) { + ProgressState.COMPLETED -> colorResource(id = R.color.backgroundSuccess) + ProgressState.FAILED -> colorResource(id = R.color.backgroundDanger) + else -> colorResource(id = R.color.backgroundInfo) + } + Column( + modifier = modifier + .fillMaxWidth() + ) { + Text( + text = title, + modifier = Modifier.align(Alignment.CenterHorizontally), + fontSize = 14.sp, + color = colorResource(id = R.color.textDarkest) + ) + LinearProgressIndicator( + progress = progress / 100f, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + color = progressColor, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ProgressScreenPreview() { + ProgressScreen( + progressUiState = ProgressUiState( + title = "All modules and items", + progressTitle = "Publishing", + progress = 40f, + note = "Modules and items that have already been processed will not be reverted to their previous state when the process is discontinued.", + state = ProgressState.RUNNING + ), + actionHandler = {} + ) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/mvvm/ViewState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/mvvm/ViewState.kt index 748f133f3d..95dfaa04ce 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/mvvm/ViewState.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/mvvm/ViewState.kt @@ -31,7 +31,7 @@ sealed class ViewState { object Refresh : ViewState() object LoadingNextPage : ViewState() data class Empty(@StringRes val emptyTitle: Int? = null, @StringRes val emptyMessage: Int? = null, @DrawableRes val emptyImage: Int? = null) : ViewState() - data class Error(val errorMessage: String = "") : ViewState() + data class Error(val errorMessage: String = "", @DrawableRes val errorImage: Int? = null) : ViewState() fun isInLoadingState(): Boolean { return this is Loading || this is Refresh || this is LoadingNextPage 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 29902a27b1..d171a90bd3 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,8 +3,26 @@ package com.instructure.pandautils.room.appdatabase import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import com.instructure.pandautils.room.appdatabase.daos.* -import com.instructure.pandautils.room.appdatabase.entities.* +import com.instructure.pandautils.room.appdatabase.daos.AttachmentDao +import com.instructure.pandautils.room.appdatabase.daos.AuthorDao +import com.instructure.pandautils.room.appdatabase.daos.DashboardFileUploadDao +import com.instructure.pandautils.room.appdatabase.daos.EnvironmentFeatureFlagsDao +import com.instructure.pandautils.room.appdatabase.daos.FileUploadInputDao +import com.instructure.pandautils.room.appdatabase.daos.MediaCommentDao +import com.instructure.pandautils.room.appdatabase.daos.ModuleBulkProgressDao +import com.instructure.pandautils.room.appdatabase.daos.PendingSubmissionCommentDao +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.pandautils.room.appdatabase.daos.SubmissionCommentDao +import com.instructure.pandautils.room.appdatabase.entities.AttachmentEntity +import com.instructure.pandautils.room.appdatabase.entities.AuthorEntity +import com.instructure.pandautils.room.appdatabase.entities.DashboardFileUploadEntity +import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFlags +import com.instructure.pandautils.room.appdatabase.entities.FileUploadInputEntity +import com.instructure.pandautils.room.appdatabase.entities.MediaCommentEntity +import com.instructure.pandautils.room.appdatabase.entities.ModuleBulkProgressEntity +import com.instructure.pandautils.room.appdatabase.entities.PendingSubmissionCommentEntity +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity +import com.instructure.pandautils.room.appdatabase.entities.SubmissionCommentEntity import com.instructure.pandautils.room.common.Converters @Database( @@ -16,8 +34,10 @@ import com.instructure.pandautils.room.common.Converters MediaCommentEntity::class, SubmissionCommentEntity::class, PendingSubmissionCommentEntity::class, - DashboardFileUploadEntity::class - ], version = 8 + DashboardFileUploadEntity::class, + ReminderEntity::class, + ModuleBulkProgressEntity::class + ], version = 10 ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -37,4 +57,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun dashboardFileUploadDao(): DashboardFileUploadDao abstract fun environmentFeatureFlagsDao(): EnvironmentFeatureFlagsDao + + abstract fun reminderDao(): ReminderDao + + abstract fun moduleBulkProgressDao(): ModuleBulkProgressDao } \ 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 f7cb1e1095..8f17252874 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 @@ -53,5 +53,13 @@ val appDatabaseMigrations = arrayOf( createMigration(7, 8) { database -> database.execSQL("CREATE TABLE IF NOT EXISTS EnvironmentFeatureFlags (userId INTEGER NOT NULL, featureFlags TEXT NOT NULL, PRIMARY KEY(userId))") + }, + + createMigration(8, 9) { database -> + database.execSQL("CREATE TABLE IF NOT EXISTS ReminderEntity (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, userId INTEGER NOT NULL, assignmentId INTEGER NOT NULL, htmlUrl TEXT NOT NULL, name TEXT NOT NULL, text TEXT NOT NULL, time INTEGER NOT NULL)") + }, + + createMigration(9, 10) { database -> + database.execSQL("CREATE TABLE IF NOT EXISTS `ModuleBulkProgressEntity` (`progressId` INTEGER NOT NULL, `allModules` INTEGER NOT NULL, `skipContentTags` INTEGER NOT NULL, `action` TEXT NOT NULL, `courseId` INTEGER NOT NULL, `affectedIds` TEXT NOT NULL, PRIMARY KEY(`progressId`))") } ) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ModuleBulkProgressDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ModuleBulkProgressDao.kt new file mode 100644 index 0000000000..83f7c7e623 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ModuleBulkProgressDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 - 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.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Upsert +import com.instructure.pandautils.room.appdatabase.entities.ModuleBulkProgressEntity + +@Dao +interface ModuleBulkProgressDao { + + @Upsert + suspend fun insert(moduleBulkProgressEntity: ModuleBulkProgressEntity) + + @Delete + suspend fun delete(moduleBulkProgressEntity: ModuleBulkProgressEntity) + + @Query("SELECT * FROM ModuleBulkProgressEntity WHERE courseId = :courseId") + suspend fun findByCourseId(courseId: Long): List + + @Query("DELETE FROM ModuleBulkProgressEntity WHERE progressId = :progressId") + suspend fun deleteById(progressId: Long) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt new file mode 100644 index 0000000000..0f3a32495d --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 - 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.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity + +@Dao +interface ReminderDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(reminder: ReminderEntity): Long + + @Query("DELETE FROM ReminderEntity WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("DELETE FROM ReminderEntity WHERE time < :time") + suspend fun deletePastReminders(time: Long) + + @Query("SELECT * FROM ReminderEntity WHERE userId = :userId AND assignmentId = :assignmentId") + fun findByAssignmentIdLiveData(userId: Long, assignmentId: Long): LiveData> + + @Query("SELECT * FROM ReminderEntity WHERE userId = :userId") + suspend fun findByUserId(userId: Long): List +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ModuleBulkProgressEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ModuleBulkProgressEntity.kt new file mode 100644 index 0000000000..177dfdd16a --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ModuleBulkProgressEntity.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 - 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 ModuleBulkProgressEntity( + @PrimaryKey + val progressId: Long, + val allModules: Boolean, + val skipContentTags: Boolean, + val action: String, + val courseId: Long, + val affectedIds: List +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt new file mode 100644 index 0000000000..e98f494de8 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 - 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 ReminderEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val userId: Long, + val assignmentId: Long, + val htmlUrl: String, + val name: String, + val text: String, + val time: Long +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabase.kt index 0dd9d648e3..b455e6f507 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabase.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabase.kt @@ -219,7 +219,7 @@ import com.instructure.pandautils.room.offline.entities.UserEntity DiscussionTopicEntity::class, CourseSyncProgressEntity::class, FileSyncProgressEntity::class, - ], version = 1 + ], version = 2 ) @TypeConverters(value = [Converters::class, OfflineConverters::class]) abstract class OfflineDatabase : RoomDatabase() { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt index 066f17bf82..c80837d6b6 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt @@ -17,7 +17,82 @@ package com.instructure.pandautils.room.offline -import androidx.room.migration.Migration import com.instructure.pandautils.room.createMigration -val offlineDatabaseMigrations = arrayOf() \ No newline at end of file +val offlineDatabaseMigrations = arrayOf( + + createMigration(1, 2) { database -> + //CourseSyncProgressEntity + database.execSQL("CREATE TABLE IF NOT EXISTS `CourseSyncProgressEntity_temp` (`courseId` INTEGER NOT NULL," + + "`courseName` TEXT NOT NULL," + + "`tabs` TEXT NOT NULL," + + "`additionalFilesStarted` INTEGER NOT NULL," + + "`progressState` TEXT NOT NULL, PRIMARY KEY(`courseId`))") + database.execSQL( + "INSERT INTO CourseSyncProgressEntity_temp (courseId, courseName, tabs, additionalFilesStarted, progressState) " + + "SELECT courseId, courseName, tabs, additionalFilesStarted, progressState FROM CourseSyncProgressEntity" + ) + database.execSQL("DROP TABLE CourseSyncProgressEntity") + database.execSQL("ALTER TABLE CourseSyncProgressEntity_temp RENAME TO CourseSyncProgressEntity") + + //FileSyncProgressEntity + database.execSQL( + "CREATE TABLE FileSyncProgressEntity_temp (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + + "fileId INTEGER NOT NULL," + + "courseId INTEGER NOT NULL," + + "fileName TEXT NOT NULL," + + "progress INTEGER NOT NULL," + + "fileSize INTEGER NOT NULL," + + "additionalFile INTEGER NOT NULL DEFAULT 0," + + "progressState TEXT NOT NULL," + + "FOREIGN KEY(courseId) REFERENCES CourseSyncProgressEntity(courseId) ON DELETE CASCADE" + + ")" + ) + database.execSQL( + "INSERT INTO FileSyncProgressEntity_temp (fileId, courseId, fileName, progress, fileSize, additionalFile, progressState) " + + "SELECT fileId, courseId, fileName, progress, fileSize, additionalFile, progressState FROM FileSyncProgressEntity" + ) + database.execSQL("DROP TABLE FileSyncProgressEntity") + database.execSQL("ALTER TABLE FileSyncProgressEntity_temp RENAME TO FileSyncProgressEntity") + + //LockInfoEntity + database.execSQL( + "CREATE TABLE IF NOT EXISTS `LockInfoEntity_temp` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + + "`modulePrerequisiteNames` TEXT," + + "`unlockAt` TEXT," + + "`lockedModuleId` INTEGER," + + "`assignmentId` INTEGER," + + "`moduleId` INTEGER," + + "`pageId` INTEGER," + + "FOREIGN KEY(`moduleId`) REFERENCES `ModuleContentDetailsEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED," + + "FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED," + + "FOREIGN KEY(`pageId`) REFERENCES `PageEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" + ) + database.execSQL( + "INSERT INTO LockInfoEntity_temp (id, modulePrerequisiteNames, unlockAt, lockedModuleId, assignmentId, moduleId, pageId) " + + "SELECT id, modulePrerequisiteNames, unlockAt, lockedModuleId, assignmentId, moduleId, pageId FROM LockInfoEntity" + ) + database.execSQL("DROP TABLE LockInfoEntity") + database.execSQL("ALTER TABLE LockInfoEntity_temp RENAME TO LockInfoEntity") + + //LockedModuleEntity + database.execSQL( + "CREATE TABLE IF NOT EXISTS `LockedModuleEntity_temp` (" + + "`id` INTEGER NOT NULL," + + "`contextId` INTEGER NOT NULL," + + "`contextType` TEXT," + + "`name` TEXT," + + "`unlockAt` TEXT," + + "`isRequireSequentialProgress` INTEGER NOT NULL," + + "`lockInfoId` INTEGER, PRIMARY KEY(`id`)," + + "FOREIGN KEY(`lockInfoId`) REFERENCES `LockInfoEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + database.execSQL( + "INSERT INTO LockedModuleEntity_temp (id, contextId, contextType, name, unlockAt, isRequireSequentialProgress) " + + "SELECT id, contextId, contextType, name, unlockAt, isRequireSequentialProgress FROM LockedModuleEntity" + ) + database.execSQL("DROP TABLE LockedModuleEntity") + database.execSQL("ALTER TABLE LockedModuleEntity_temp RENAME TO LockedModuleEntity") + }, +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/typeface/TypefaceBehavior.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/typeface/TypefaceBehavior.kt index 0ef8b68b92..c9cda529ac 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/typeface/TypefaceBehavior.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/typeface/TypefaceBehavior.kt @@ -18,9 +18,9 @@ package com.instructure.pandautils.typeface import android.content.Context import android.graphics.Typeface -import android.graphics.fonts.FontFamily +import com.instructure.pandautils.compose.overrideComposeFonts +import com.instructure.pandautils.utils.CanvasFont import java.lang.reflect.Field -import java.lang.reflect.Type const val REGULAR_FONT_KEY = "sans-serif" const val MEDIUM_FONT_KEY = "sans-serif-medium" @@ -29,9 +29,11 @@ class TypefaceBehavior(private val context: Context) { private var fontOverriden = false - fun overrideFont(fontPath: String) { + fun overrideFont(canvasFont: CanvasFont) { if (fontOverriden) return + val fontPath = canvasFont.fontPath + val typefaceMap: Map = mapOf( REGULAR_FONT_KEY to fontPath, MEDIUM_FONT_KEY to fontPath @@ -45,10 +47,13 @@ class TypefaceBehavior(private val context: Context) { val updatedSystemMap = mutableMapOf() updatedSystemMap.putAll(fontMap) staticField.set(null, updatedSystemMap) + + overrideComposeFonts(canvasFont.fontRes) fontOverriden = true } catch (e: Exception) { e.printStackTrace() } + } fun resetFonts() { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/update/UpdateManager.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/update/UpdateManager.kt index ca17a1a4e0..2bd2898b0e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/update/UpdateManager.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/update/UpdateManager.kt @@ -76,7 +76,9 @@ class UpdateManager(private val appUpdateManager: AppUpdateManager, .build(), IMMEDIATE_UPDATE_REQUEST_CODE ) - } else if (appUpdateInfo.updatePriority() >= FLEXIBLE_THRESHOLD && appUpdateInfo.clientVersionStalenessDays() ?: 0 >= DAYS_FOR_FLEXIBLE_UPDATE) { + } else if (appUpdateInfo.updatePriority() >= FLEXIBLE_THRESHOLD && (appUpdateInfo.clientVersionStalenessDays() + ?: 0) >= DAYS_FOR_FLEXIBLE_UPDATE + ) { val listener = InstallStateUpdatedListener { if (it.installStatus() == InstallStatus.DOWNLOADED) { registerNotificationChannel(activity) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FontFamily.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/CanvasFont.kt similarity index 75% rename from libs/pandautils/src/main/java/com/instructure/pandautils/utils/FontFamily.kt rename to libs/pandautils/src/main/java/com/instructure/pandautils/utils/CanvasFont.kt index 711f50fde2..c5f6c640b6 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FontFamily.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/CanvasFont.kt @@ -16,7 +16,9 @@ package com.instructure.pandautils.utils -enum class FontFamily(val fontPath: String) { - REGULAR("fonts/lato_regular.ttf"), - K5("fonts/balsamiq_regular.ttf") +import com.instructure.pandautils.R + +enum class CanvasFont(val fontPath: String, val fontRes: Int) { + REGULAR("fonts/lato_regular.ttf", R.font.lato_regular), + K5("fonts/balsamiq_regular.ttf", R.font.balsamiq_regular) } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Extensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Extensions.kt index c16d98d61c..8b4c390f06 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Extensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Extensions.kt @@ -17,8 +17,8 @@ package com.instructure.pandautils.utils import androidx.work.Data -import androidx.work.WorkInfo import com.google.gson.Gson +import kotlinx.coroutines.delay import java.util.* import kotlin.math.ln import kotlin.math.pow @@ -59,3 +59,36 @@ fun Data.newBuilder(): Data.Builder { return Data.Builder() .putAll(this) } + +suspend fun retry(retryCount: Int = 5, initialDelay: Long = 100, factor: Float = 2f, maxDelay: Long = 1000, block: suspend () -> Unit) { + var currentDelay = initialDelay + repeat(retryCount.coerceAtLeast(1)) { + try { + block() + return + } catch (e: Exception) { + delay(currentDelay) + currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay) + } + } +} + +suspend fun poll( + pollInterval: Long = 1000, + maxAttempts: Int = 10, + block: suspend () -> T?, + validate: suspend (T) -> Boolean +): T? { + var attempts = 0 + while (attempts < maxAttempts || maxAttempts == -1) { + val result = block() + result?.let { + if (validate(it)) { + return result + } + } + attempts++ + delay(pollInterval) + } + return null +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/NetworkStateProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/NetworkStateProvider.kt index d5f7016a6e..c12384121b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/NetworkStateProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/NetworkStateProvider.kt @@ -33,6 +33,8 @@ interface NetworkStateProvider { class NetworkStateProviderImpl(context: Context) : NetworkStateProvider { private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val networkCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + private val hasActiveNetwork = networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).orDefault() private val _isOnlineLiveData = MutableLiveData() @@ -40,10 +42,7 @@ class NetworkStateProviderImpl(context: Context) : NetworkStateProvider { get() = _isOnlineLiveData init { - val networkCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) - val hasActiveNetwork = networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).orDefault() _isOnlineLiveData.postValue(hasActiveNetwork) - connectivityManager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { super.onAvailable(network) @@ -58,6 +57,6 @@ class NetworkStateProviderImpl(context: Context) : NetworkStateProvider { } override fun isOnline(): Boolean { - return _isOnlineLiveData.value.orDefault() + return _isOnlineLiveData.value ?: hasActiveNetwork } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/views/EmptyView.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/views/EmptyView.kt index 503c3c682d..402c781647 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/views/EmptyView.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/views/EmptyView.kt @@ -25,6 +25,7 @@ import android.view.View import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView +import androidx.annotation.DrawableRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat @@ -172,11 +173,16 @@ class EmptyView @JvmOverloads constructor( } } - fun setError(errorMessage: String) = with(binding) { + fun setError(errorMessage: String, @DrawableRes errorImage: Int?) = with(binding) { title.setVisible() - image.setGone() + if (errorImage != null) { + image.setImageResource(errorImage) + image.setVisible() + } else { + centerTitle() + image.setGone() + } loading.root.setGone() - centerTitle() titleText = errorMessage messageText = "" diff --git a/libs/pandautils/src/main/res/drawable/bg_rounded_rectangle.xml b/libs/pandautils/src/main/res/drawable/bg_rounded_rectangle.xml index f5d7aafd76..b4dc54eafe 100644 --- a/libs/pandautils/src/main/res/drawable/bg_rounded_rectangle.xml +++ b/libs/pandautils/src/main/res/drawable/bg_rounded_rectangle.xml @@ -18,7 +18,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> - + diff --git a/libs/pandautils/src/main/res/layout/fragment_discussion_details_web_view.xml b/libs/pandautils/src/main/res/layout/fragment_discussion_details_web_view.xml index 0ccc49687d..3d6bd6b2f8 100644 --- a/libs/pandautils/src/main/res/layout/fragment_discussion_details_web_view.xml +++ b/libs/pandautils/src/main/res/layout/fragment_discussion_details_web_view.xml @@ -35,19 +35,13 @@ app:layout_constraintTop_toTopOf="parent" app:theme="@style/ToolBarStyle" /> - - - - + app:layout_constraintTop_toBottomOf="@id/toolbar" + app:url="@{viewModel.data.url}" /> + + + + + \ No newline at end of file diff --git a/libs/pandautils/src/main/res/values-ja/strings.xml b/libs/pandautils/src/main/res/values-ja/strings.xml index 14904e4b51..508e963bc4 100644 --- a/libs/pandautils/src/main/res/values-ja/strings.xml +++ b/libs/pandautils/src/main/res/values-ja/strings.xml @@ -154,7 +154,7 @@ 小テストなし すみません、まだ小テストは存在しません。 表示する公開されている小テストはありません。 - アナウンスメントなし + 現在、アナウンスメントはありません まだなにもアナウンスされていません コースなし このアカウントに関連付けられているコースはないようです。今日コースを作成するには、ウェブをご覧ください。 @@ -332,7 +332,7 @@ ロック日をロック解除日より前にすることはできません 課題対象者を空白にすることはできません ~へ割り当てられました - 次から使用可能 + 開始日時 利用できる人 利用可能期間の開始日付 利用可能期間の開始時間 diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/list/filter/ContextFilterViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/list/filter/ContextFilterViewModelTest.kt index 087abfd2fe..308ff10317 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/list/filter/ContextFilterViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/list/filter/ContextFilterViewModelTest.kt @@ -80,12 +80,12 @@ class ContextFilterViewModelTest { @Test fun `Clicking items sends event with id`() { - val canvasContext = listOf(Course(id = 1, name = "Course")) + val canvasContext = listOf(Course(id = 1L, name = "Course")) viewModel.setFilterItems(canvasContext) val listItems = viewModel.itemViewModels.value ?: emptyList() (listItems[1] as ContextFilterItemViewModel).onClicked() - assertEquals(1, viewModel.events.value!!.peekContent()) + assertEquals(1L, viewModel.events.value!!.peekContent()) } } \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModelTest.kt index b0b7b7f04f..512a401470 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModelTest.kt @@ -81,9 +81,9 @@ class PushNotificationPreferencesViewModelTest { val notificationResponse = NotificationPreferenceResponse( notificationPreferences = listOf( NotificationPreference(notification = "notification1", category = "due_date", frequency = "immediately"), - NotificationPreference(notification = "notification2", category = "membership_update", frequency = "immediately"), + NotificationPreference(notification = "notification2", category = "conversation_message", frequency = "immediately"), NotificationPreference(notification = "notification3", category = "discussion", frequency = "never"), - NotificationPreference(notification = "notification4", category = "announcement_created_by_you", frequency = "never") + NotificationPreference(notification = "notification4", category = "announcement", frequency = "never") ) ) @@ -97,7 +97,7 @@ class PushNotificationPreferencesViewModelTest { val data = viewModel.data.value - assertEquals(3, viewModel.data.value?.items?.size) + assertEquals(3, data?.items?.size) //Course Activities val courseActivitiesHeader = data?.items?.get(0) @@ -113,10 +113,10 @@ class PushNotificationPreferencesViewModelTest { assertEquals(1, courseActivitiesItems?.get(0)?.data?.position) assertEquals(true, courseActivitiesItems?.get(0)?.isChecked) - //Announcement Created By You - assertEquals("Announcement Created By You", courseActivitiesItems?.get(1)?.data?.title) - assertEquals("Get notified when you create an announcement and when somebody replies to your announcement.", courseActivitiesItems?.get(1)?.data?.description) - assertEquals(6, courseActivitiesItems?.get(1)?.data?.position) + //Announcement + assertEquals("Announcement", courseActivitiesItems?.get(1)?.data?.title) + assertEquals("Get notified when there is a new announcement in your course.", courseActivitiesItems?.get(1)?.data?.description) + assertEquals(5, courseActivitiesItems?.get(1)?.data?.position) assertEquals(false, courseActivitiesItems?.get(1)?.isChecked) //Discussions @@ -133,19 +133,19 @@ class PushNotificationPreferencesViewModelTest { assertEquals(1, discussionItems?.get(0)?.data?.position) assertEquals(false, discussionItems?.get(0)?.isChecked) - //Groups - val groupsHeader = data?.items?.get(2) - assertEquals("Groups", groupsHeader?.data?.title) - assertEquals(4, groupsHeader?.data?.position) - assertEquals(1, groupsHeader?.itemViewModels?.size) + //Conversations + val conversationsHeader = data?.items?.get(2) + assertEquals("Conversations", conversationsHeader?.data?.title) + assertEquals(2, conversationsHeader?.data?.position) + assertEquals(1, conversationsHeader?.itemViewModels?.size) //Membership update - val groupsItems = groupsHeader?.itemViewModels as? List - assertEquals(1, groupsItems?.size) - assertEquals("Membership Update", groupsItems?.get(0)?.data?.title) - assertEquals("Admin only, pending enrollment activated. Get notified when a group enrollment is accepted or rejected.", groupsItems?.get(0)?.data?.description) - assertEquals(1, groupsItems?.get(0)?.data?.position) - assertEquals(true, groupsItems?.get(0)?.isChecked) + val conversationsItems = conversationsHeader?.itemViewModels as? List + assertEquals(1, conversationsItems?.size) + assertEquals("Conversation Message", conversationsItems?.get(0)?.data?.title) + assertEquals("Get notified when you have a new inbox message.", conversationsItems?.get(0)?.data?.description) + assertEquals(2, conversationsItems?.get(0)?.data?.position) + assertEquals(true, conversationsItems?.get(0)?.isChecked) } @Test @@ -335,6 +335,80 @@ class PushNotificationPreferencesViewModelTest { assertEquals(1, viewModel.data.value?.items?.size) } + @Test + fun `Notification categories filtered correctly`() { + val notificationResponse = NotificationPreferenceResponse( + notificationPreferences = listOf( + NotificationPreference(notification = "notification1", category = "announcement", frequency = "immediately"), + NotificationPreference(notification = "notification2", category = "due_date", frequency = "immediately"), + NotificationPreference(notification = "notification3", category = "course_content", frequency = "immediately"), + NotificationPreference(notification = "notification4", category = "grading_policies", frequency = "immediately"), + NotificationPreference(notification = "notification5", category = "grading", frequency = "immediately"), + NotificationPreference(notification = "notification6", category = "calendar", frequency = "immediately"), + NotificationPreference(notification = "notification7", category = "invitation", frequency = "immediately"), + NotificationPreference(notification = "notification8", category = "registration", frequency = "immediately"), + NotificationPreference(notification = "notification9", category = "discussion", frequency = "immediately"), + NotificationPreference(notification = "notification10", category = "late_grading", frequency = "immediately"), + NotificationPreference(notification = "notification11", category = "submission_comment", frequency = "immediately"), + NotificationPreference(notification = "notification12", category = "summaries", frequency = "immediately"), + NotificationPreference(notification = "notification13", category = "other", frequency = "immediately"), + NotificationPreference(notification = "notification14", category = "reminder", frequency = "immediately"), + NotificationPreference(notification = "notification15", category = "membership_update", frequency = "immediately"), + NotificationPreference(notification = "notification16", category = "discussion_entry", frequency = "immediately"), + NotificationPreference(notification = "notification17", category = "migration", frequency = "immediately"), + NotificationPreference(notification = "notification18", category = "all_submissions", frequency = "immediately"), + NotificationPreference(notification = "notification19", category = "conversation_message", frequency = "immediately"), + NotificationPreference(notification = "notification20", category = "added_to_conversation", frequency = "immediately"), + NotificationPreference(notification = "notification21", category = "alert", frequency = "immediately"), + NotificationPreference(notification = "notification22", category = "student_appointment_signups", frequency = "immediately"), + NotificationPreference(notification = "notification23", category = "appointment_cancelations", frequency = "immediately"), + NotificationPreference(notification = "notification24", category = "appointment_availability", frequency = "immediately"), + NotificationPreference(notification = "notification25", category = "appointment_signups", frequency = "immediately"), + NotificationPreference(notification = "notification26", category = "files", frequency = "immediately"), + NotificationPreference(notification = "notification27", category = "announcement_created_by_you", frequency = "immediately"), + NotificationPreference(notification = "notification28", category = "conversation_created", frequency = "immediately"), + NotificationPreference(notification = "notification29", category = "recording_ready", frequency = "immediately"), + NotificationPreference(notification = "notification30", category = "blueprint", frequency = "immediately"), + NotificationPreference(notification = "notification31", category = "content_link_error", frequency = "immediately"), + NotificationPreference(notification = "notification32", category = "account_notification", frequency = "immediately"), + NotificationPreference(notification = "notification33", category = "discussion_mention", frequency = "immediately"), + NotificationPreference(notification = "notification34", category = "reported_reply", frequency = "immediately") + ) + ) + + every { notificationPreferencesManager.getNotificationPreferencesAsync(any(), any(), any()) } returns mockk { + coEvery { await() } returns DataResult.Success(notificationResponse) + } + + val viewModel = createViewModel() + + viewModel.data.observe(lifecycleOwner) {} + + val expected = listOf( + "notification2", + "notification3", + "notification1", + "notification5", + "notification7", + "notification11", + "notification9", + "notification16", + "notification19", + "notification22", + "notification23", + "notification24", + "notification6" + ) + + val actual = viewModel.data.value?.items?.flatMap { header -> + header.itemViewModels.map { + it.data.notification + } + } + + assertEquals(expected, actual) + } + private fun createViewModel(): PushNotificationPreferencesViewModel { return PushNotificationPreferencesViewModel(communicationChannelsManager, notificationPreferencesManager, apiPrefs, notificationPreferenceUtils, resources) } @@ -342,15 +416,15 @@ class PushNotificationPreferencesViewModelTest { private fun setupStrings() { every { resources.getString(R.string.notification_pref_due_date) } returns "Due Date" every { resources.getString(R.string.notification_pref_discussion) } returns "Discussion" - every { resources.getString(R.string.notification_pref_announcement_created_by_you) } returns "Announcement Created By You" - every { resources.getString(R.string.notification_pref_membership_update) } returns "Membership Update" + every { resources.getString(R.string.notification_pref_announcement) } returns "Announcement" + every { resources.getString(R.string.notification_pref_conversation_message) } returns "Conversation Message" every { resources.getString(R.string.notification_desc_due_date) } returns "Get notified when an assignment due date changes." - every { resources.getString(R.string.notification_desc_announcement_created_by_you) } returns "Get notified when you create an announcement and when somebody replies to your announcement." + every { resources.getString(R.string.notification_desc_announcement) } returns "Get notified when there is a new announcement in your course." every { resources.getString(R.string.notification_desc_discussion) } returns "Get notified when there’s a new discussion topic in your course." - every { resources.getString(R.string.notification_desc_membership_update) } returns "Admin only, pending enrollment activated. Get notified when a group enrollment is accepted or rejected." + every { resources.getString(R.string.notification_desc_conversation_message) } returns "Get notified when you have a new inbox message." every { resources.getString(R.string.notification_cat_course_activities) } returns "Course Activities" every { resources.getString(R.string.notification_cat_discussions) } returns "Discussions" - every { resources.getString(R.string.notification_cat_groups) } returns "Groups" + every { resources.getString(R.string.notification_cat_conversations) } returns "Conversations" every { resources.getString(R.string.errorOccurred) } returns "An unexpected error occurred." } } \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/progress/ProgressViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/progress/ProgressViewModelTest.kt new file mode 100644 index 0000000000..b38adb38d1 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/progress/ProgressViewModelTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2024 - 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.progress + +import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.apis.ProgressAPI +import com.instructure.canvasapi2.models.Progress +import com.instructure.canvasapi2.utils.DataResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class ProgressViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher(TestCoroutineScheduler()) + + private val stateHandle: SavedStateHandle = mockk(relaxed = true) + private val progressApi: ProgressAPI.ProgressInterface = mockk(relaxed = true) + private val progressPreferences: ProgressPreferences = mockk(relaxed = true) + + private lateinit var viewModel: ProgressViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + every { stateHandle.get("progressId") } returns 1L + every { stateHandle.get("title") } returns "Title" + every { stateHandle.get("progressTitle") } returns "Publishing" + every { stateHandle.get("note") } returns "Note" + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + @Test + fun `Emit progress updates`() { + var progress = Progress( + id = 1L, + workflowState = "running", + completion = 0f + ) + var expectedState = ProgressUiState( + title = "Title", + progressTitle = "Publishing", + progress = 0f, + note = "Note", + state = ProgressState.RUNNING, + ) + + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(progress) + + viewModel = createViewModel() + assertEquals(expectedState, viewModel.uiState.value) + + progress = progress.copy(completion = 11.11f) + expectedState = expectedState.copy(progress = 11.11f) + + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(progress) + + testDispatcher.scheduler.advanceTimeBy(510) + assertEquals(expectedState, viewModel.uiState.value) + + progress = progress.copy(completion = 100f, workflowState = "completed") + expectedState = expectedState.copy(progress = 100f, state = ProgressState.COMPLETED) + + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(progress) + + testDispatcher.scheduler.advanceTimeBy(510) + assertEquals(expectedState, viewModel.uiState.value) + } + + @Test + fun `Emit success state`() { + val progress = Progress( + id = 1L, + workflowState = "completed", + completion = 100f + ) + val expectedState = ProgressUiState( + title = "Title", + progressTitle = "Publishing", + progress = 100f, + note = "Note", + state = ProgressState.COMPLETED, + ) + + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(progress) + + viewModel = createViewModel() + + assertEquals(expectedState, viewModel.uiState.value) + } + + @Test + fun `Emit failed state on exception`() { + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Fail() + + viewModel = createViewModel() + + assertEquals(ProgressState.FAILED, viewModel.uiState.value.state) + } + + @Test + fun `Cancel event sends close action`() = runTest { + val progress = Progress( + id = 1L, + workflowState = "failed", + completion = 11.11f + ) + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(progress) + coEvery { + progressApi.cancelProgress( + any(), + any() + ) + } returns DataResult.Success(progress.copy(workflowState = "failed")) + + viewModel = createViewModel() + + viewModel.handleAction(ProgressAction.Cancel) + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + assert(events.last() is ProgressViewModelAction.Close) + + coVerify { + progressApi.cancelProgress("1", any()) + } + } + + private fun createViewModel() = ProgressViewModel(stateHandle, progressApi, progressPreferences) + + +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/StorageUtilsTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/StorageUtilsTest.kt index ac6d3b5bc9..c18c91745b 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/StorageUtilsTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/StorageUtilsTest.kt @@ -23,6 +23,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -37,6 +38,7 @@ class StorageUtilsTest { mockkStatic(Environment::class) } + @After fun tearDown() { unmockkAll() }